@hypersolid-dev/eslint-config 4.0.0 → 5.0.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 +24 -0
- package/README.md +37 -6
- package/dist/base.js +52 -7
- package/dist/plugins/hypersolid.d.ts +3 -0
- package/dist/plugins/hypersolid.js +38 -0
- package/dist/plugins/props-destructuring.js +2 -1
- package/dist/plugins/react-native-a11y/index.js +4 -2
- package/dist/plugins/react-native-a11y/rules/accessibility-hint.js +4 -2
- package/dist/plugins/react-native-a11y/rules/accessibility-label.js +8 -4
- package/dist/plugins/react-native-a11y/rules/accessibility-role.js +4 -2
- package/dist/plugins/react-native-a11y/rules/gate-announce-for-accessibility.js +10 -6
- package/dist/plugins/react-native-a11y/rules/input-purpose.js +8 -4
- package/dist/plugins/react-native-a11y/rules/no-unnamed-interactive.js +4 -2
- package/dist/plugins/react-native-a11y/rules/require-invert-colors-opt-out.js +6 -4
- package/dist/plugins/react-native-a11y/rules/require-modal-a11y-on-sheets.js +5 -3
- package/dist/plugins/react-native-a11y/utils/get-amount-of-text-children.js +8 -4
- package/dist/plugins/react-native-a11y/utils/get-element-name.js +1 -1
- package/dist/plugins/rules/boolean-guard.d.ts +30 -0
- package/dist/plugins/rules/boolean-guard.js +143 -0
- package/dist/plugins/rules/comment-style.d.ts +26 -0
- package/dist/plugins/rules/comment-style.js +158 -0
- package/dist/plugins/rules/effect-takes-callback.d.ts +20 -0
- package/dist/plugins/rules/effect-takes-callback.js +106 -0
- package/dist/plugins/rules/jsdoc-for-props.d.ts +18 -0
- package/dist/plugins/rules/jsdoc-for-props.js +57 -0
- package/dist/plugins/rules/no-callback-payload.d.ts +21 -0
- package/dist/plugins/rules/no-callback-payload.js +214 -0
- package/dist/plugins/rules/no-markup-variable.d.ts +22 -0
- package/dist/plugins/rules/no-markup-variable.js +117 -0
- package/dist/plugins/rules/no-unnecessary-use-callback.d.ts +24 -0
- package/dist/plugins/rules/no-unnecessary-use-callback.js +117 -0
- package/dist/plugins/rules/regex-pattern-const.d.ts +19 -0
- package/dist/plugins/rules/regex-pattern-const.js +82 -0
- package/dist/plugins/rules/test-id-default.d.ts +4 -0
- package/dist/plugins/rules/test-id-default.js +186 -0
- package/dist/plugins/rules/test-id-format.d.ts +4 -0
- package/dist/plugins/rules/test-id-format.js +122 -0
- package/dist/plugins/rules/test-id-from-props.d.ts +4 -0
- package/dist/plugins/rules/test-id-from-props.js +94 -0
- package/dist/plugins/rules/undefined-check.d.ts +5 -0
- package/dist/plugins/rules/undefined-check.js +64 -0
- package/dist/plugins/rules/webview-allow-list.d.ts +5 -0
- package/dist/plugins/rules/webview-allow-list.js +109 -0
- package/dist/plugins/utils/comments.d.ts +15 -0
- package/dist/plugins/utils/comments.js +15 -0
- package/dist/plugins/utils/component-name.d.ts +11 -0
- package/dist/plugins/utils/component-name.js +32 -0
- package/dist/plugins/utils/create-rule.d.ts +5 -0
- package/dist/plugins/utils/create-rule.js +4 -0
- package/dist/plugins/utils/expressions.d.ts +52 -0
- package/dist/plugins/utils/expressions.js +274 -0
- package/dist/plugins/utils/types.d.ts +20 -0
- package/dist/plugins/utils/types.js +89 -0
- package/dist/react-native.js +65 -12
- package/dist/shared.d.ts +19 -0
- package/dist/shared.js +72 -1
- package/package.json +2 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
const WRAPPERS = new Set(["memo", "forwardRef"]);
|
|
3
|
+
const CAPITALISED_PATTERN = /^[A-Z]/;
|
|
4
|
+
/** A `memo(…)`/`forwardRef(…)` call, also as `React.memo(…)`. */
|
|
5
|
+
export const isWrapperCall = (node) => {
|
|
6
|
+
if (node?.type !== AST_NODE_TYPES.CallExpression)
|
|
7
|
+
return false;
|
|
8
|
+
const { callee } = node;
|
|
9
|
+
if (callee.type === AST_NODE_TYPES.Identifier)
|
|
10
|
+
return WRAPPERS.has(callee.name);
|
|
11
|
+
return (callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
12
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
13
|
+
WRAPPERS.has(callee.property.name));
|
|
14
|
+
};
|
|
15
|
+
const declaredName = (holder) => {
|
|
16
|
+
let node = holder;
|
|
17
|
+
while (isWrapperCall(node))
|
|
18
|
+
node = node.parent;
|
|
19
|
+
return node?.type === AST_NODE_TYPES.VariableDeclarator && node.id.type === AST_NODE_TYPES.Identifier
|
|
20
|
+
? node.id.name
|
|
21
|
+
: undefined;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The name a function is a component under, if it is one: a capitalised
|
|
25
|
+
* function declaration or named function expression (`memo(function Row() {})`),
|
|
26
|
+
* or a capitalised const holding the function directly or through any nesting
|
|
27
|
+
* of `memo`/`forwardRef` (`memo(forwardRef(…))`).
|
|
28
|
+
*/
|
|
29
|
+
export const componentName = (fn) => {
|
|
30
|
+
const name = fn.id?.name ?? declaredName(fn.parent);
|
|
31
|
+
return name && CAPITALISED_PATTERN.test(name) ? name : undefined;
|
|
32
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
declare const createRule: <Options extends readonly unknown[], MessageIds extends string>({ meta, name, ...rule }: Readonly<ESLintUtils.RuleWithMetaAndName<Options, MessageIds, unknown>>) => ESLintUtils.RuleModule<MessageIds, Options, unknown, ESLintUtils.RuleListener> & {
|
|
3
|
+
name: string;
|
|
4
|
+
};
|
|
5
|
+
export default createRule;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { TSESLint, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* One answer to "what can this expression be", for every rule that has to ask.
|
|
4
|
+
*
|
|
5
|
+
* The carriers (the shapes that hold an expression rather than being one) are
|
|
6
|
+
* flattened away, and what comes back is the list of leaves: the things the
|
|
7
|
+
* expression can actually turn out to be.
|
|
8
|
+
*/
|
|
9
|
+
type Node = TSESTree.Node;
|
|
10
|
+
type Scope = TSESLint.Scope.Scope;
|
|
11
|
+
export type ScopeContext = {
|
|
12
|
+
sourceCode: {
|
|
13
|
+
getScope: (node: Node) => Scope;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type LeavesOptions = {
|
|
17
|
+
/** Follow an identifier to what it holds (default true). */
|
|
18
|
+
names?: boolean;
|
|
19
|
+
/** Follow `OBJ.key` to that entry (default true). */
|
|
20
|
+
members?: boolean;
|
|
21
|
+
/** Follow a function, and a call of a local function, to each of its returns (default false). */
|
|
22
|
+
returns?: boolean;
|
|
23
|
+
/** The names whose `.select({ … })` is unwrapped like `Platform.select` (default `Platform`). */
|
|
24
|
+
selectObjects?: ReadonlySet<string>;
|
|
25
|
+
/** Extra calls whose first argument is the expression. */
|
|
26
|
+
calls?: (node: TSESTree.CallExpression) => boolean;
|
|
27
|
+
/** Internal cycle guard, threaded through the `Platform.select` recursion. */
|
|
28
|
+
seen?: Set<Node>;
|
|
29
|
+
};
|
|
30
|
+
/** Through `as`, `satisfies`, `!` and `?.`. */
|
|
31
|
+
export declare function unwrap(node: Node | null | undefined): Node | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* A property's key when it is written literally (`a`, `"a"`, `1`, `["a"]`,
|
|
34
|
+
* `[\`a\`]`), else null: a computed key that is a variable, a call or a
|
|
35
|
+
* template with expressions, a spread and a pattern have no key to read.
|
|
36
|
+
*/
|
|
37
|
+
export declare function keyOf(property: Node): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Every leaf an expression can produce.
|
|
40
|
+
*
|
|
41
|
+
* Flattened away: arrays, ternaries, `&&` / `||` / `??`, `as` / `satisfies` /
|
|
42
|
+
* `!` wrappers, a `Platform.select` call (each branch's values), a `useMemo`
|
|
43
|
+
* callback (its body or returns, also as `React.useMemo`), a `const` name (its
|
|
44
|
+
* initialiser, through any number of hops), a destructured `const` name when
|
|
45
|
+
* the initialiser is a literal, and `OBJ.key` when `OBJ` is a local `const`
|
|
46
|
+
* object literal. A `useCallback` callback yields the function itself, or its
|
|
47
|
+
* returns when `returns` is on. What cannot be read is handed back as a leaf
|
|
48
|
+
* itself: a name from another file, a `let`, a method call, a computed member,
|
|
49
|
+
* a spread. A cycle yields nothing rather than looping.
|
|
50
|
+
*/
|
|
51
|
+
export declare function leavesOf(context: ScopeContext, node: Node | null | undefined, options?: LeavesOptions): Node[];
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
const { DefinitionType } = TSESLint.Scope;
|
|
3
|
+
// A memo hook holds one computed value, so its body is the value.
|
|
4
|
+
const MEMO_HOOKS = new Set(['useMemo', 'useCallback']);
|
|
5
|
+
const FUNCTIONS = new Set(['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration']);
|
|
6
|
+
const TS_WRAPPERS = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression']);
|
|
7
|
+
const DEFAULT_SELECT_OBJECTS = new Set(['Platform']);
|
|
8
|
+
const isWrapper = (node) => TS_WRAPPERS.has(node.type);
|
|
9
|
+
const isFunction = (node) => FUNCTIONS.has(node.type);
|
|
10
|
+
/** Through `as`, `satisfies`, `!` and `?.`. */
|
|
11
|
+
export function unwrap(node) {
|
|
12
|
+
let current = node ?? undefined;
|
|
13
|
+
while (current && isWrapper(current))
|
|
14
|
+
current = current.expression;
|
|
15
|
+
return current;
|
|
16
|
+
}
|
|
17
|
+
/** The name of the memo hook a callee calls (`useMemo`, `React.useMemo`), if it is one. */
|
|
18
|
+
function memoHookName(callee) {
|
|
19
|
+
if (callee.type === AST_NODE_TYPES.Identifier)
|
|
20
|
+
return MEMO_HOOKS.has(callee.name) ? callee.name : undefined;
|
|
21
|
+
if (callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
22
|
+
!callee.computed &&
|
|
23
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
24
|
+
callee.object.name === 'React' &&
|
|
25
|
+
MEMO_HOOKS.has(callee.property.name)) {
|
|
26
|
+
return callee.property.name;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
function findVariable(scope, name) {
|
|
31
|
+
for (let current = scope; current; current = current.upper) {
|
|
32
|
+
const variable = current.set.get(name);
|
|
33
|
+
if (variable)
|
|
34
|
+
return variable;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function bindingOf(context, identifier) {
|
|
39
|
+
return findVariable(context.sourceCode.getScope(identifier), identifier.name);
|
|
40
|
+
}
|
|
41
|
+
/** The declarator a name resolves to, when it is a local with an initialiser; `.init` need not be the name's own value. */
|
|
42
|
+
function localDeclarator(context, identifier) {
|
|
43
|
+
const definition = bindingOf(context, identifier)?.defs[0];
|
|
44
|
+
return definition?.type === DefinitionType.Variable && definition.node.init ? definition.node : undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A property's key when it is written literally (`a`, `"a"`, `1`, `["a"]`,
|
|
48
|
+
* `[\`a\`]`), else null: a computed key that is a variable, a call or a
|
|
49
|
+
* template with expressions, a spread and a pattern have no key to read.
|
|
50
|
+
*/
|
|
51
|
+
export function keyOf(property) {
|
|
52
|
+
if (property.type !== AST_NODE_TYPES.Property)
|
|
53
|
+
return null;
|
|
54
|
+
const { key } = property;
|
|
55
|
+
if (!property.computed)
|
|
56
|
+
return key.type === AST_NODE_TYPES.Identifier ? key.name : String(key.value);
|
|
57
|
+
if (key.type === AST_NODE_TYPES.Literal)
|
|
58
|
+
return String(key.value);
|
|
59
|
+
return key.type === AST_NODE_TYPES.TemplateLiteral && key.expressions.length === 0 ? (key.quasis[0]?.value.cooked ?? null) : null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The expression a destructuring pattern hands to `name`, read off a literal
|
|
63
|
+
* initialiser. A non-literal value, a rest element, a computed key and a spread
|
|
64
|
+
* before the wanted element all give up, since the value is not in this file.
|
|
65
|
+
*/
|
|
66
|
+
function picked(pattern, init, name) {
|
|
67
|
+
if (pattern.type === AST_NODE_TYPES.Identifier)
|
|
68
|
+
return pattern.name === name ? (init ?? undefined) : undefined;
|
|
69
|
+
// The default only applies when the source has nothing there.
|
|
70
|
+
if (pattern.type === AST_NODE_TYPES.AssignmentPattern)
|
|
71
|
+
return picked(pattern.left, init ?? pattern.right, name);
|
|
72
|
+
const source = unwrap(init);
|
|
73
|
+
if (pattern.type === AST_NODE_TYPES.ObjectPattern && source?.type === AST_NODE_TYPES.ObjectExpression) {
|
|
74
|
+
for (const property of pattern.properties) {
|
|
75
|
+
const wanted = keyOf(property);
|
|
76
|
+
if (wanted === null || property.type !== AST_NODE_TYPES.Property)
|
|
77
|
+
continue;
|
|
78
|
+
const entry = source.properties.find((candidate) => keyOf(candidate) === wanted);
|
|
79
|
+
const found = picked(property.value, entry?.type === AST_NODE_TYPES.Property ? entry.value : undefined, name);
|
|
80
|
+
if (found)
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (pattern.type === AST_NODE_TYPES.ArrayPattern && source?.type === AST_NODE_TYPES.ArrayExpression) {
|
|
86
|
+
for (const [index, element] of pattern.elements.entries()) {
|
|
87
|
+
if (!element)
|
|
88
|
+
continue;
|
|
89
|
+
const before = source.elements.slice(0, index + 1);
|
|
90
|
+
if (before.some((entry) => entry?.type === AST_NODE_TYPES.SpreadElement))
|
|
91
|
+
continue;
|
|
92
|
+
const found = picked(element, source.elements[index], name);
|
|
93
|
+
if (found)
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* What a name stands for: a `const`'s own value, or a declared function itself.
|
|
101
|
+
* A `let` or `var` is not followed, and a destructured `const` resolves only
|
|
102
|
+
* when its initialiser is a literal holding the matching entry.
|
|
103
|
+
*/
|
|
104
|
+
function boundTo(context, identifier) {
|
|
105
|
+
const definition = bindingOf(context, identifier)?.defs[0];
|
|
106
|
+
if (definition?.type === DefinitionType.FunctionName)
|
|
107
|
+
return definition.node;
|
|
108
|
+
if (definition?.type !== DefinitionType.Variable)
|
|
109
|
+
return undefined;
|
|
110
|
+
if (definition.parent.kind !== 'const' || !definition.node.init)
|
|
111
|
+
return undefined;
|
|
112
|
+
const { id, init } = definition.node;
|
|
113
|
+
return id.type === AST_NODE_TYPES.Identifier ? init : picked(id, init, identifier.name);
|
|
114
|
+
}
|
|
115
|
+
function selectBranches(node, selectObjects) {
|
|
116
|
+
const { callee } = node;
|
|
117
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed)
|
|
118
|
+
return undefined;
|
|
119
|
+
if (callee.property.name !== 'select' || callee.object.type !== AST_NODE_TYPES.Identifier)
|
|
120
|
+
return undefined;
|
|
121
|
+
return selectObjects.has(callee.object.name) ? node.arguments[0] : undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Every `return` with an argument in a body, including nested blocks but not nested functions. */
|
|
124
|
+
function returnsIn(body) {
|
|
125
|
+
const found = [];
|
|
126
|
+
const visit = (value) => {
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
for (const item of value)
|
|
129
|
+
visit(item);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!value || typeof value.type !== 'string')
|
|
133
|
+
return;
|
|
134
|
+
const statement = value;
|
|
135
|
+
if (statement.type === AST_NODE_TYPES.ReturnStatement) {
|
|
136
|
+
if (statement.argument)
|
|
137
|
+
found.push(statement.argument);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (isFunction(statement))
|
|
141
|
+
return;
|
|
142
|
+
for (const [key, child] of Object.entries(statement)) {
|
|
143
|
+
if (key !== 'parent')
|
|
144
|
+
visit(child);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
visit(body);
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Every leaf an expression can produce.
|
|
152
|
+
*
|
|
153
|
+
* Flattened away: arrays, ternaries, `&&` / `||` / `??`, `as` / `satisfies` /
|
|
154
|
+
* `!` wrappers, a `Platform.select` call (each branch's values), a `useMemo`
|
|
155
|
+
* callback (its body or returns, also as `React.useMemo`), a `const` name (its
|
|
156
|
+
* initialiser, through any number of hops), a destructured `const` name when
|
|
157
|
+
* the initialiser is a literal, and `OBJ.key` when `OBJ` is a local `const`
|
|
158
|
+
* object literal. A `useCallback` callback yields the function itself, or its
|
|
159
|
+
* returns when `returns` is on. What cannot be read is handed back as a leaf
|
|
160
|
+
* itself: a name from another file, a `let`, a method call, a computed member,
|
|
161
|
+
* a spread. A cycle yields nothing rather than looping.
|
|
162
|
+
*/
|
|
163
|
+
export function leavesOf(context, node, options = {}) {
|
|
164
|
+
const { names = true, members = true, returns = false, selectObjects = DEFAULT_SELECT_OBJECTS, calls, seen = new Set(), } = options;
|
|
165
|
+
const out = [];
|
|
166
|
+
const walkFunction = (fn) => {
|
|
167
|
+
if (fn.body.type !== AST_NODE_TYPES.BlockStatement) {
|
|
168
|
+
walk(fn.body);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const argument of returnsIn(fn.body))
|
|
172
|
+
walk(argument);
|
|
173
|
+
};
|
|
174
|
+
const didWalkCall = (call) => {
|
|
175
|
+
const branches = selectBranches(call, selectObjects);
|
|
176
|
+
if (branches) {
|
|
177
|
+
// Each branch is a value for whatever slot the call sits in.
|
|
178
|
+
for (const branch of leavesOf(context, branches, { ...options, seen })) {
|
|
179
|
+
if (branch.type !== AST_NODE_TYPES.ObjectExpression)
|
|
180
|
+
continue;
|
|
181
|
+
for (const property of branch.properties) {
|
|
182
|
+
if (property.type === AST_NODE_TYPES.Property)
|
|
183
|
+
walk(property.value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
if (calls?.(call)) {
|
|
189
|
+
walk(call.arguments[0]);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
// A call to a local function is what it returns (a factory); a method call is never followed.
|
|
193
|
+
if (returns && call.callee.type === AST_NODE_TYPES.Identifier) {
|
|
194
|
+
const bound = boundTo(context, call.callee);
|
|
195
|
+
if (bound && isFunction(bound)) {
|
|
196
|
+
walk(bound);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const hook = memoHookName(call.callee);
|
|
201
|
+
const callback = call.arguments.at(0);
|
|
202
|
+
if (!hook || !callback || !isFunction(callback))
|
|
203
|
+
return false;
|
|
204
|
+
// A callback's value is the function itself; `returns` decides whether the function case follows on.
|
|
205
|
+
if (hook === 'useCallback')
|
|
206
|
+
walk(callback);
|
|
207
|
+
else
|
|
208
|
+
walkFunction(callback);
|
|
209
|
+
return true;
|
|
210
|
+
};
|
|
211
|
+
const walk = (current) => {
|
|
212
|
+
if (!current || seen.has(current))
|
|
213
|
+
return;
|
|
214
|
+
seen.add(current);
|
|
215
|
+
if (isWrapper(current)) {
|
|
216
|
+
walk(current.expression);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
switch (current.type) {
|
|
220
|
+
case AST_NODE_TYPES.ArrayExpression:
|
|
221
|
+
for (const element of current.elements)
|
|
222
|
+
walk(element);
|
|
223
|
+
return;
|
|
224
|
+
case AST_NODE_TYPES.ConditionalExpression:
|
|
225
|
+
walk(current.consequent);
|
|
226
|
+
walk(current.alternate);
|
|
227
|
+
return;
|
|
228
|
+
case AST_NODE_TYPES.LogicalExpression:
|
|
229
|
+
walk(current.left);
|
|
230
|
+
walk(current.right);
|
|
231
|
+
return;
|
|
232
|
+
case AST_NODE_TYPES.Identifier: {
|
|
233
|
+
const bound = names ? boundTo(context, current) : undefined;
|
|
234
|
+
if (bound) {
|
|
235
|
+
walk(bound);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
case AST_NODE_TYPES.MemberExpression: {
|
|
241
|
+
if (!members || current.computed || current.object.type !== AST_NODE_TYPES.Identifier)
|
|
242
|
+
break;
|
|
243
|
+
const declarator = localDeclarator(context, current.object);
|
|
244
|
+
// A destructured name is not what the whole initialiser holds.
|
|
245
|
+
const held = declarator?.id.type === AST_NODE_TYPES.Identifier ? unwrap(declarator.init) : undefined;
|
|
246
|
+
if (held?.type !== AST_NODE_TYPES.ObjectExpression)
|
|
247
|
+
break;
|
|
248
|
+
const key = current.property.name;
|
|
249
|
+
const entry = held.properties.find((property) => keyOf(property) === key);
|
|
250
|
+
if (entry?.type === AST_NODE_TYPES.Property) {
|
|
251
|
+
walk(entry.value);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case AST_NODE_TYPES.CallExpression:
|
|
257
|
+
if (didWalkCall(current))
|
|
258
|
+
return;
|
|
259
|
+
break;
|
|
260
|
+
case AST_NODE_TYPES.ArrowFunctionExpression:
|
|
261
|
+
case AST_NODE_TYPES.FunctionExpression:
|
|
262
|
+
case AST_NODE_TYPES.FunctionDeclaration:
|
|
263
|
+
if (!returns)
|
|
264
|
+
break;
|
|
265
|
+
walkFunction(current);
|
|
266
|
+
return;
|
|
267
|
+
default:
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
out.push(current);
|
|
271
|
+
};
|
|
272
|
+
walk(node);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
/**
|
|
4
|
+
* Whether every value of this type is truthy, `undefined` aside.
|
|
5
|
+
*
|
|
6
|
+
* Safe: `true`, `symbol`, `object`, a non-falsy literal, `undefined`, `void`,
|
|
7
|
+
* `never`, and an object type that is callable or to which none of `""`, `0`
|
|
8
|
+
* and `false` is assignable. Unions and intersections are read part by part,
|
|
9
|
+
* unresolved types through their constraint. Everything else is not claimed.
|
|
10
|
+
*
|
|
11
|
+
* @param checker Needed to resolve a constraint and to ask whether a falsy
|
|
12
|
+
* primitive fits an object type; without one only a callable object is claimed.
|
|
13
|
+
*/
|
|
14
|
+
export declare const isSameAsBangBang: (type: ts.Type, checker?: ts.TypeChecker) => boolean;
|
|
15
|
+
export type UndefinedComparison = {
|
|
16
|
+
value: TSESTree.Expression | TSESTree.PrivateIdentifier;
|
|
17
|
+
isPresent: boolean;
|
|
18
|
+
};
|
|
19
|
+
/** `x !== undefined` / `undefined === x`; `present` is what the check wants. */
|
|
20
|
+
export declare const undefinedComparison: (node: TSESTree.Node | undefined) => UndefinedComparison | undefined;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
import ts, { TypeFlags } from "typescript";
|
|
3
|
+
/**
|
|
4
|
+
* The type question two rules ask: is `x !== undefined` the same test as `x`?
|
|
5
|
+
*
|
|
6
|
+
* `boolean-guard` asks it of a render guard and `undefined-check` of any
|
|
7
|
+
* condition, and getting it wrong in either direction is a behaviour change,
|
|
8
|
+
* so it is answered once, here.
|
|
9
|
+
*
|
|
10
|
+
* `undefined` has to be the *only* falsy value the type admits. `null`, `0`,
|
|
11
|
+
* `""`, `false` and a bare `any` each make the two tests disagree on a value
|
|
12
|
+
* the code can actually hold.
|
|
13
|
+
*
|
|
14
|
+
* Answered by naming what is safe rather than what is not: a type this cannot
|
|
15
|
+
* resolve (`Items[K]`, a conditional, a `keyof`, `{}`) is unsafe, which costs
|
|
16
|
+
* a report and never a changed branch.
|
|
17
|
+
*/
|
|
18
|
+
/** Resolved before asking: a parameter, an `Items[K]`, a conditional, a `keyof`. */
|
|
19
|
+
const UNRESOLVED = TypeFlags.TypeParameter |
|
|
20
|
+
TypeFlags.IndexedAccess |
|
|
21
|
+
TypeFlags.Conditional |
|
|
22
|
+
TypeFlags.Index |
|
|
23
|
+
TypeFlags.Substitution;
|
|
24
|
+
/**
|
|
25
|
+
* Whether every value of this type is truthy, `undefined` aside.
|
|
26
|
+
*
|
|
27
|
+
* Safe: `true`, `symbol`, `object`, a non-falsy literal, `undefined`, `void`,
|
|
28
|
+
* `never`, and an object type that is callable or to which none of `""`, `0`
|
|
29
|
+
* and `false` is assignable. Unions and intersections are read part by part,
|
|
30
|
+
* unresolved types through their constraint. Everything else is not claimed.
|
|
31
|
+
*
|
|
32
|
+
* @param checker Needed to resolve a constraint and to ask whether a falsy
|
|
33
|
+
* primitive fits an object type; without one only a callable object is claimed.
|
|
34
|
+
*/
|
|
35
|
+
export const isSameAsBangBang = (type, checker) => {
|
|
36
|
+
const isEach = (part) => isSameAsBangBang(part, checker);
|
|
37
|
+
if (type.isUnion() || type.isIntersection())
|
|
38
|
+
return type.types.every(isEach);
|
|
39
|
+
if (type.flags & UNRESOLVED) {
|
|
40
|
+
const constraint = type.isTypeParameter() ? type.getConstraint() : checker?.getBaseConstraintOfType(type);
|
|
41
|
+
// No constraint means no bound on what it holds.
|
|
42
|
+
return !!constraint && isEach(constraint);
|
|
43
|
+
}
|
|
44
|
+
if (type.flags & (TypeFlags.Undefined | TypeFlags.Void | TypeFlags.Never)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (type.flags & TypeFlags.BooleanLiteral) {
|
|
48
|
+
return type.intrinsicName === "true";
|
|
49
|
+
}
|
|
50
|
+
if (type.flags & (TypeFlags.ESSymbolLike | TypeFlags.NonPrimitive)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (type.flags & TypeFlags.Object) {
|
|
54
|
+
/**
|
|
55
|
+
* A callable or constructable type is truthy. Any other object type is only
|
|
56
|
+
* as good as what the falsy primitives do against it: `{}`, `Object` and
|
|
57
|
+
* `{ length: number }` all accept `""`, `0` or `false`.
|
|
58
|
+
*/
|
|
59
|
+
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0)
|
|
60
|
+
return true;
|
|
61
|
+
if (!checker)
|
|
62
|
+
return false;
|
|
63
|
+
return [checker.getStringLiteralType(""), checker.getNumberLiteralType(0), checker.getFalseType()].every((falsy) => !checker.isTypeAssignableTo(falsy, type));
|
|
64
|
+
}
|
|
65
|
+
// A literal is as truthy as its value: only `""`, `0` and `0n` are falsy.
|
|
66
|
+
if (type.isStringLiteral())
|
|
67
|
+
return type.value !== "";
|
|
68
|
+
if (type.isNumberLiteral())
|
|
69
|
+
return type.value !== 0;
|
|
70
|
+
if (type.flags & TypeFlags.BigIntLiteral) {
|
|
71
|
+
return type.value.base10Value !== "0";
|
|
72
|
+
}
|
|
73
|
+
// A primitive, `any`, `unknown`, or a flag not named above.
|
|
74
|
+
return false;
|
|
75
|
+
};
|
|
76
|
+
const isUndefined = (side) => side.type === AST_NODE_TYPES.Identifier && side.name === "undefined";
|
|
77
|
+
/** `x !== undefined` / `undefined === x`; `present` is what the check wants. */
|
|
78
|
+
export const undefinedComparison = (node) => {
|
|
79
|
+
if (node?.type !== AST_NODE_TYPES.BinaryExpression)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (node.operator !== "!==" && node.operator !== "===")
|
|
82
|
+
return undefined;
|
|
83
|
+
const isPresent = node.operator === "!==";
|
|
84
|
+
if (isUndefined(node.left) && !isUndefined(node.right))
|
|
85
|
+
return { value: node.right, isPresent };
|
|
86
|
+
if (isUndefined(node.right) && !isUndefined(node.left))
|
|
87
|
+
return { value: node.left, isPresent };
|
|
88
|
+
return undefined;
|
|
89
|
+
};
|
package/dist/react-native.js
CHANGED
|
@@ -4,12 +4,14 @@ import { createNodeResolver } from 'eslint-plugin-import-x';
|
|
|
4
4
|
import globals from 'globals';
|
|
5
5
|
import noSecrets from 'eslint-plugin-no-secrets';
|
|
6
6
|
import reactNativeA11y from './plugins/react-native-a11y/index.js';
|
|
7
|
-
import { CODE_FILES, NUMBER_GLOBAL_RESTRICTIONS, raiseWarnings, REACT_BOOLEAN_NAME_OPTIONS, reactConfigs, TEST_FILES, } from './shared.js';
|
|
7
|
+
import { CODE_FILES, NUMBER_GLOBAL_RESTRICTIONS, raiseWarnings, REACT_BOOLEAN_NAME_OPTIONS, REACT_RESTRICTED_SYNTAX, reactConfigs, TEST_FILES, } from './shared.js';
|
|
8
8
|
const A11Y = '@hypersolid-dev/react-native-a11y';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Derived instead of hardcoded: the frozen `confusing-browser-globals` list (last released 2021) misses `document`,
|
|
11
|
+
* `localStorage` and `getComputedStyle`, and bans `self`, which React Native defines. `globals` is maintained,
|
|
12
|
+
* so new browser APIs are picked up without editing this file.
|
|
13
|
+
* Globals Hermes provides that `globals['react-native']` does not list yet; remove a name once `globals` adds it.
|
|
14
|
+
*/
|
|
13
15
|
const HERMES_GLOBALS = new Set(['atob', 'btoa', 'TextEncoder']);
|
|
14
16
|
const BROWSER_GLOBALS = Object.keys(globals.browser).filter((name) => !(name in globals['react-native']) && !HERMES_GLOBALS.has(name));
|
|
15
17
|
// High-entropy strings in Expo and native config that are identifiers, not credentials.
|
|
@@ -21,8 +23,10 @@ const NO_SECRETS_IGNORE = [
|
|
|
21
23
|
'^[A-Z][A-Za-z0-9]*\\/[A-Z][A-Za-z0-9]*$', // i18n `Namespace/Key` paths
|
|
22
24
|
'^data:', // Lottie and embedded images
|
|
23
25
|
];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Metro picks `Button.ios.tsx` for `./Button` on iOS; the resolvers must try the same platform files, or
|
|
28
|
+
* `import-x/no-unresolved` flags every platform-split module. Platform files first, as Metro resolves them.
|
|
29
|
+
*/
|
|
26
30
|
const RESOLVE_EXTENSIONS = ['.ios', '.android', '.native', '.web', ''].flatMap((platform) => ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.json'].map((extension) => `${platform}${extension}`));
|
|
27
31
|
// Metro's default asset extensions plus Expo's additions, and json (source or asset); from eslint-config-expo.
|
|
28
32
|
const METRO_ASSET_EXTENSIONS = [
|
|
@@ -70,9 +74,11 @@ export default function reactNative() {
|
|
|
70
74
|
message: `\`${name}\` is a browser global, undefined on React Native: a local of that name was meant, or the value comes from a React Native API.`,
|
|
71
75
|
})),
|
|
72
76
|
],
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Unicorn rules that push toward APIs the React Native runtime lacks (Hermes in RN 0.85 and RN's fetch/Blob/URL
|
|
79
|
+
* polyfills); the suggested fix crashes at runtime. Re-check when RN bumps Hermes.
|
|
80
|
+
* `toSorted` is missing from Hermes. Bad: `values.toSorted(compare)` suggested for `values.sort(compare)`.
|
|
81
|
+
*/
|
|
76
82
|
'unicorn/no-array-sort': 'off',
|
|
77
83
|
// Iterator helpers are missing from Hermes. Bad: `map.values().map(double).toArray()`.
|
|
78
84
|
'unicorn/prefer-iterator-helpers': 'off',
|
|
@@ -99,6 +105,51 @@ export default function reactNative() {
|
|
|
99
105
|
],
|
|
100
106
|
// High-entropy strings that look like credentials, minus Expo and native identifiers. Bad: `const token = 'ghp_Zx8kQ2vLm9…'`.
|
|
101
107
|
'no-secrets/no-secrets': ['error', { ignoreContent: NO_SECRETS_IGNORE }],
|
|
108
|
+
// The React entries, plus `pointerEvents` as a prop: it belongs in the style. Bad: `<View pointerEvents="none" />`.
|
|
109
|
+
'no-restricted-syntax': [
|
|
110
|
+
'error',
|
|
111
|
+
...REACT_RESTRICTED_SYNTAX,
|
|
112
|
+
{
|
|
113
|
+
selector: ":matches(JSXAttribute[name.name='pointerEvents'], JSXSpreadAttribute > ObjectExpression > Property[key.name='pointerEvents'])",
|
|
114
|
+
message: "Put `pointerEvents` in the style (`pointerEvents: 'none'`); the prop is deprecated.",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
// Deprecated or JS-thread APIs from `react-native`. Bad: `InteractionManager.runAfterInteractions(load)`, `import { Animated } from 'react-native'`.
|
|
118
|
+
'@typescript-eslint/no-restricted-imports': [
|
|
119
|
+
'error',
|
|
120
|
+
{
|
|
121
|
+
paths: [
|
|
122
|
+
{
|
|
123
|
+
name: 'react-native',
|
|
124
|
+
importNames: ['InteractionManager'],
|
|
125
|
+
message: 'Deprecated in the New Architecture. Use `requestIdleCallback` or `startTransition`.',
|
|
126
|
+
allowTypeImports: true,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'react-native',
|
|
130
|
+
importNames: ['Animated'],
|
|
131
|
+
message: 'Use react-native-reanimated, which runs animations on the UI thread; `Animated` runs them on the JS thread and needs `useNativeDriver`.',
|
|
132
|
+
allowTypeImports: true,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
// `testID` is lowercase kebab-case, so test selectors match exactly. Bad: `testID="Header.Title"`.
|
|
138
|
+
'@hypersolid-dev/test-id-format': 'error',
|
|
139
|
+
// A WebView allows every origin and filters each navigation in `onShouldStartLoadWithRequest`. Bad: `<WebView source={{ uri }} />`.
|
|
140
|
+
'@hypersolid-dev/webview-allow-list': 'error',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
// Reusable components only: a screen is one instance, so a written id is fine there.
|
|
145
|
+
name: '@hypersolid-dev/react-native/components',
|
|
146
|
+
files: ['**/components/**/*.tsx'],
|
|
147
|
+
ignores: TEST_FILES,
|
|
148
|
+
rules: {
|
|
149
|
+
// A component's `testID` comes from its props, so two instances stay apart. Bad: `<View testID="card" />` inside `Card`.
|
|
150
|
+
'@hypersolid-dev/test-id-from-props': 'error',
|
|
151
|
+
// The `testID` prop defaults to the component's kebab-case name. Bad: `({ testID }: Props)` in `Card` (use `testID = 'card'`).
|
|
152
|
+
'@hypersolid-dev/test-id-default': 'error',
|
|
102
153
|
},
|
|
103
154
|
},
|
|
104
155
|
{
|
|
@@ -160,8 +211,10 @@ export default function reactNative() {
|
|
|
160
211
|
[`${A11Y}/no-unnamed-interactive`]: 'error',
|
|
161
212
|
// Images opt out of iOS Smart Invert or render as a negative. Bad: `<Image source={source} accessibilityLabel="A cat" />`.
|
|
162
213
|
[`${A11Y}/require-invert-colors-opt-out`]: 'error',
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Off: a sheet is whatever a project calls one, so the rule has no defaults. Enable it with the project's sheet components:
|
|
216
|
+
* `['error', { additionalComponents: ['BottomSheet'] }]`. Bad then: `<BottomSheet><Text>Body</Text></BottomSheet>`.
|
|
217
|
+
*/
|
|
165
218
|
[`${A11Y}/require-modal-a11y-on-sheets`]: 'off',
|
|
166
219
|
},
|
|
167
220
|
},
|
package/dist/shared.d.ts
CHANGED
|
@@ -12,6 +12,25 @@ export declare const NUMBER_GLOBAL_RESTRICTIONS: {
|
|
|
12
12
|
name: string;
|
|
13
13
|
message: string;
|
|
14
14
|
}[];
|
|
15
|
+
/** `no-restricted-syntax` entries per preset; a later config replaces the options, so each preset spreads the ones before it. */
|
|
16
|
+
export declare const BASE_RESTRICTED_SYNTAX: {
|
|
17
|
+
selector: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}[];
|
|
20
|
+
export declare const REACT_RESTRICTED_SYNTAX: {
|
|
21
|
+
selector: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}[];
|
|
24
|
+
/** `@typescript-eslint/naming-convention` entries per preset, for the same reason. Destructured names come from elsewhere (`const { user_id } = payload`). */
|
|
25
|
+
export declare const NAMING_CONVENTION: ({
|
|
26
|
+
selector: string;
|
|
27
|
+
modifiers: string[];
|
|
28
|
+
format: null;
|
|
29
|
+
} | {
|
|
30
|
+
selector: string;
|
|
31
|
+
format: string[];
|
|
32
|
+
modifiers?: undefined;
|
|
33
|
+
})[];
|
|
15
34
|
export declare function raiseWarnings(configs: Linter.Config[]): Linter.Config[];
|
|
16
35
|
export declare function scopeToCode(configs: readonly Linter.Config[]): Linter.Config[];
|
|
17
36
|
export declare function reactConfigs(): Linter.Config[];
|