@hypersolid-dev/eslint-config 4.0.0 → 6.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +54 -7
  3. package/dist/base.js +65 -7
  4. package/dist/jest.d.ts +3 -0
  5. package/dist/jest.js +56 -0
  6. package/dist/plugins/hypersolid.d.ts +3 -0
  7. package/dist/plugins/hypersolid.js +38 -0
  8. package/dist/plugins/props-destructuring.js +14 -13
  9. package/dist/plugins/react-native-a11y/index.js +4 -2
  10. package/dist/plugins/react-native-a11y/rules/accessibility-actions-need-handler.js +2 -2
  11. package/dist/plugins/react-native-a11y/rules/accessibility-hint.js +6 -4
  12. package/dist/plugins/react-native-a11y/rules/accessibility-label.js +10 -6
  13. package/dist/plugins/react-native-a11y/rules/accessibility-role.js +6 -4
  14. package/dist/plugins/react-native-a11y/rules/accessibility-value-needs-range.js +25 -27
  15. package/dist/plugins/react-native-a11y/rules/adjustable-needs-value.js +16 -18
  16. package/dist/plugins/react-native-a11y/rules/gate-announce-for-accessibility.js +14 -10
  17. package/dist/plugins/react-native-a11y/rules/input-purpose.js +10 -6
  18. package/dist/plugins/react-native-a11y/rules/no-aria-legacy-mix.js +20 -22
  19. package/dist/plugins/react-native-a11y/rules/no-deprecated-accessibility-focus.js +12 -14
  20. package/dist/plugins/react-native-a11y/rules/no-dropped-aria-on-text.js +2 -2
  21. package/dist/plugins/react-native-a11y/rules/no-font-scaling-disabled.js +24 -26
  22. package/dist/plugins/react-native-a11y/rules/no-ineffective-accessibility-props.js +34 -36
  23. package/dist/plugins/react-native-a11y/rules/no-invalid-accessibility-state.js +31 -33
  24. package/dist/plugins/react-native-a11y/rules/no-nested-focus-stops.js +2 -2
  25. package/dist/plugins/react-native-a11y/rules/no-press-in-activation.js +2 -2
  26. package/dist/plugins/react-native-a11y/rules/no-unnamed-interactive.js +6 -4
  27. package/dist/plugins/react-native-a11y/rules/require-invert-colors-opt-out.js +8 -6
  28. package/dist/plugins/react-native-a11y/rules/require-modal-a11y-on-sheets.js +7 -5
  29. package/dist/plugins/react-native-a11y/utils/get-amount-of-text-children.js +19 -13
  30. package/dist/plugins/react-native-a11y/utils/get-element-name.js +1 -1
  31. package/dist/plugins/rules/boolean-guard.d.ts +30 -0
  32. package/dist/plugins/rules/boolean-guard.js +143 -0
  33. package/dist/plugins/rules/comment-style.d.ts +26 -0
  34. package/dist/plugins/rules/comment-style.js +167 -0
  35. package/dist/plugins/rules/effect-takes-callback.d.ts +20 -0
  36. package/dist/plugins/rules/effect-takes-callback.js +106 -0
  37. package/dist/plugins/rules/jsdoc-for-props.d.ts +18 -0
  38. package/dist/plugins/rules/jsdoc-for-props.js +57 -0
  39. package/dist/plugins/rules/no-callback-payload.d.ts +21 -0
  40. package/dist/plugins/rules/no-callback-payload.js +217 -0
  41. package/dist/plugins/rules/no-markup-variable.d.ts +22 -0
  42. package/dist/plugins/rules/no-markup-variable.js +123 -0
  43. package/dist/plugins/rules/no-unnecessary-use-callback.d.ts +24 -0
  44. package/dist/plugins/rules/no-unnecessary-use-callback.js +117 -0
  45. package/dist/plugins/rules/regex-pattern-const.d.ts +19 -0
  46. package/dist/plugins/rules/regex-pattern-const.js +82 -0
  47. package/dist/plugins/rules/test-id-default.d.ts +4 -0
  48. package/dist/plugins/rules/test-id-default.js +182 -0
  49. package/dist/plugins/rules/test-id-format.d.ts +4 -0
  50. package/dist/plugins/rules/test-id-format.js +118 -0
  51. package/dist/plugins/rules/test-id-from-props.d.ts +4 -0
  52. package/dist/plugins/rules/test-id-from-props.js +90 -0
  53. package/dist/plugins/rules/undefined-check.d.ts +5 -0
  54. package/dist/plugins/rules/undefined-check.js +64 -0
  55. package/dist/plugins/rules/webview-allow-list.d.ts +5 -0
  56. package/dist/plugins/rules/webview-allow-list.js +105 -0
  57. package/dist/plugins/utils/comments.d.ts +15 -0
  58. package/dist/plugins/utils/comments.js +15 -0
  59. package/dist/plugins/utils/component-name.d.ts +11 -0
  60. package/dist/plugins/utils/component-name.js +32 -0
  61. package/dist/plugins/utils/create-rule.d.ts +5 -0
  62. package/dist/plugins/utils/create-rule.js +4 -0
  63. package/dist/plugins/utils/expressions.d.ts +52 -0
  64. package/dist/plugins/utils/expressions.js +290 -0
  65. package/dist/plugins/utils/types.d.ts +20 -0
  66. package/dist/plugins/utils/types.js +91 -0
  67. package/dist/react-native.js +93 -12
  68. package/dist/shared.d.ts +22 -3
  69. package/dist/shared.js +202 -75
  70. package/package.json +13 -3
@@ -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,4 @@
1
+ import { ESLintUtils } from "@typescript-eslint/utils";
2
+ // Rules in this package are documented in the README, so a rule name is its own URL.
3
+ const createRule = ESLintUtils.RuleCreator((name) => name);
4
+ 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 const 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 const 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 const leavesOf: (context: ScopeContext, node: Node | null | undefined, options?: LeavesOptions) => Node[];
52
+ export {};
@@ -0,0 +1,290 @@
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 const 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
+ const 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
+ const 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
+ const bindingOf = (context, identifier) => findVariable(context.sourceCode.getScope(identifier), identifier.name);
39
+ /** The declarator a name resolves to, when it is a local with an initialiser; `.init` need not be the name's own value. */
40
+ const localDeclarator = (context, identifier) => {
41
+ const definition = bindingOf(context, identifier)?.defs[0];
42
+ return definition?.type === DefinitionType.Variable && definition.node.init ? definition.node : undefined;
43
+ };
44
+ /**
45
+ * A property's key when it is written literally (`a`, `"a"`, `1`, `["a"]`,
46
+ * `[\`a\`]`), else null: a computed key that is a variable, a call or a
47
+ * template with expressions, a spread and a pattern have no key to read.
48
+ */
49
+ export const keyOf = (property) => {
50
+ if (property.type !== AST_NODE_TYPES.Property)
51
+ return null;
52
+ const { key } = property;
53
+ if (!property.computed)
54
+ return key.type === AST_NODE_TYPES.Identifier ? key.name : String(key.value);
55
+ if (key.type === AST_NODE_TYPES.Literal)
56
+ return String(key.value);
57
+ return key.type === AST_NODE_TYPES.TemplateLiteral && key.expressions.length === 0 ? (key.quasis[0]?.value.cooked ?? null) : null;
58
+ };
59
+ /**
60
+ * The expression a destructuring pattern hands to `name`, read off a literal
61
+ * initialiser. A non-literal value, a rest element, a computed key and a spread
62
+ * before the wanted element all give up, since the value is not in this file.
63
+ */
64
+ const picked = (pattern, init, name) => {
65
+ if (pattern.type === AST_NODE_TYPES.Identifier)
66
+ return pattern.name === name ? (init ?? undefined) : undefined;
67
+ // The default only applies when the source has nothing there.
68
+ if (pattern.type === AST_NODE_TYPES.AssignmentPattern)
69
+ return picked(pattern.left, init ?? pattern.right, name);
70
+ const source = unwrap(init);
71
+ if (pattern.type === AST_NODE_TYPES.ObjectPattern && source?.type === AST_NODE_TYPES.ObjectExpression) {
72
+ return pickedFromObject(pattern, source, name);
73
+ }
74
+ if (pattern.type === AST_NODE_TYPES.ArrayPattern && source?.type === AST_NODE_TYPES.ArrayExpression) {
75
+ return pickedFromArray(pattern, source, name);
76
+ }
77
+ return undefined;
78
+ };
79
+ const pickedFromObject = (pattern, source, name) => {
80
+ for (const property of pattern.properties) {
81
+ const wanted = keyOf(property);
82
+ if (wanted === null || property.type !== AST_NODE_TYPES.Property)
83
+ continue;
84
+ const entry = source.properties.find((candidate) => keyOf(candidate) === wanted);
85
+ const found = picked(property.value, entry?.type === AST_NODE_TYPES.Property ? entry.value : undefined, name);
86
+ if (found)
87
+ return found;
88
+ }
89
+ return undefined;
90
+ };
91
+ const pickedFromArray = (pattern, source, name) => {
92
+ for (const [index, element] of pattern.elements.entries()) {
93
+ if (!element)
94
+ continue;
95
+ const before = source.elements.slice(0, index + 1);
96
+ if (before.some((entry) => entry?.type === AST_NODE_TYPES.SpreadElement))
97
+ continue;
98
+ const found = picked(element, source.elements[index], name);
99
+ if (found)
100
+ return found;
101
+ }
102
+ return undefined;
103
+ };
104
+ /**
105
+ * What a name stands for: a `const`'s own value, or a declared function itself.
106
+ * A `let` or `var` is not followed, and a destructured `const` resolves only
107
+ * when its initialiser is a literal holding the matching entry.
108
+ */
109
+ const boundTo = (context, identifier) => {
110
+ const definition = bindingOf(context, identifier)?.defs[0];
111
+ if (definition?.type === DefinitionType.FunctionName)
112
+ return definition.node;
113
+ if (definition?.type !== DefinitionType.Variable)
114
+ return undefined;
115
+ if (definition.parent.kind !== 'const' || !definition.node.init)
116
+ return undefined;
117
+ const { id, init } = definition.node;
118
+ return id.type === AST_NODE_TYPES.Identifier ? init : picked(id, init, identifier.name);
119
+ };
120
+ const selectBranches = (node, selectObjects) => {
121
+ const { callee } = node;
122
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed)
123
+ return undefined;
124
+ if (callee.property.name !== 'select' || callee.object.type !== AST_NODE_TYPES.Identifier)
125
+ return undefined;
126
+ return selectObjects.has(callee.object.name) ? node.arguments[0] : undefined;
127
+ };
128
+ /** Every `return` with an argument in a body, including nested blocks but not nested functions. */
129
+ const returnsIn = (body) => {
130
+ const found = [];
131
+ const visit = (value) => {
132
+ if (Array.isArray(value)) {
133
+ for (const item of value)
134
+ visit(item);
135
+ return;
136
+ }
137
+ if (!value || typeof value.type !== 'string')
138
+ return;
139
+ const statement = value;
140
+ if (statement.type === AST_NODE_TYPES.ReturnStatement) {
141
+ if (statement.argument)
142
+ found.push(statement.argument);
143
+ return;
144
+ }
145
+ if (isFunction(statement))
146
+ return;
147
+ for (const [key, child] of Object.entries(statement)) {
148
+ if (key !== 'parent')
149
+ visit(child);
150
+ }
151
+ };
152
+ visit(body);
153
+ return found;
154
+ };
155
+ /**
156
+ * Every leaf an expression can produce.
157
+ *
158
+ * Flattened away: arrays, ternaries, `&&` / `||` / `??`, `as` / `satisfies` /
159
+ * `!` wrappers, a `Platform.select` call (each branch's values), a `useMemo`
160
+ * callback (its body or returns, also as `React.useMemo`), a `const` name (its
161
+ * initialiser, through any number of hops), a destructured `const` name when
162
+ * the initialiser is a literal, and `OBJ.key` when `OBJ` is a local `const`
163
+ * object literal. A `useCallback` callback yields the function itself, or its
164
+ * returns when `returns` is on. What cannot be read is handed back as a leaf
165
+ * itself: a name from another file, a `let`, a method call, a computed member,
166
+ * a spread. A cycle yields nothing rather than looping.
167
+ */
168
+ export const leavesOf = (context, node, options = {}) => {
169
+ const { names = true, members = true, returns = false, selectObjects = DEFAULT_SELECT_OBJECTS, calls, seen = new Set(), } = options;
170
+ const out = [];
171
+ const walkFunction = (fn) => {
172
+ if (fn.body.type !== AST_NODE_TYPES.BlockStatement) {
173
+ walk(fn.body);
174
+ return;
175
+ }
176
+ for (const argument of returnsIn(fn.body))
177
+ walk(argument);
178
+ };
179
+ /** Each branch is a value for whatever slot the call sits in. */
180
+ const walkSelect = (branches) => {
181
+ for (const branch of leavesOf(context, branches, { ...options, seen })) {
182
+ if (branch.type !== AST_NODE_TYPES.ObjectExpression)
183
+ continue;
184
+ for (const property of branch.properties) {
185
+ if (property.type === AST_NODE_TYPES.Property)
186
+ walk(property.value);
187
+ }
188
+ }
189
+ };
190
+ /** A call to a local function is what it returns (a factory); a method call is never followed. */
191
+ const factoryOf = (call) => {
192
+ if (!returns || call.callee.type !== AST_NODE_TYPES.Identifier)
193
+ return undefined;
194
+ const bound = boundTo(context, call.callee);
195
+ return bound && isFunction(bound) ? bound : undefined;
196
+ };
197
+ const didWalkCall = (call) => {
198
+ const branches = selectBranches(call, selectObjects);
199
+ if (branches) {
200
+ walkSelect(branches);
201
+ return true;
202
+ }
203
+ if (calls?.(call)) {
204
+ walk(call.arguments[0]);
205
+ return true;
206
+ }
207
+ const factory = factoryOf(call);
208
+ if (factory) {
209
+ walk(factory);
210
+ return true;
211
+ }
212
+ const hook = memoHookName(call.callee);
213
+ const callback = call.arguments.at(0);
214
+ if (!hook || !callback || !isFunction(callback))
215
+ return false;
216
+ // A callback's value is the function itself; `returns` decides whether the function case follows on.
217
+ if (hook === 'useCallback')
218
+ walk(callback);
219
+ else
220
+ walkFunction(callback);
221
+ return true;
222
+ };
223
+ /** `OBJ.key`'s value when `OBJ` is a local `const` object literal. */
224
+ const memberEntry = (member) => {
225
+ if (member.computed || member.object.type !== AST_NODE_TYPES.Identifier)
226
+ return undefined;
227
+ const declarator = localDeclarator(context, member.object);
228
+ // A destructured name is not what the whole initialiser holds.
229
+ const held = declarator?.id.type === AST_NODE_TYPES.Identifier ? unwrap(declarator.init) : undefined;
230
+ if (held?.type !== AST_NODE_TYPES.ObjectExpression)
231
+ return undefined;
232
+ const key = member.property.name;
233
+ const entry = held.properties.find((property) => keyOf(property) === key);
234
+ return entry?.type === AST_NODE_TYPES.Property ? entry.value : undefined;
235
+ };
236
+ /** What a name or `OBJ.key` stands for, when the options let it be followed. */
237
+ const referenced = (node) => {
238
+ if (node.type === AST_NODE_TYPES.Identifier)
239
+ return names ? boundTo(context, node) : undefined;
240
+ return members ? memberEntry(node) : undefined;
241
+ };
242
+ const walk = (current) => {
243
+ if (!current || seen.has(current))
244
+ return;
245
+ seen.add(current);
246
+ if (isWrapper(current)) {
247
+ walk(current.expression);
248
+ return;
249
+ }
250
+ switch (current.type) {
251
+ case AST_NODE_TYPES.ArrayExpression:
252
+ for (const element of current.elements)
253
+ walk(element);
254
+ return;
255
+ case AST_NODE_TYPES.ConditionalExpression:
256
+ walk(current.consequent);
257
+ walk(current.alternate);
258
+ return;
259
+ case AST_NODE_TYPES.LogicalExpression:
260
+ walk(current.left);
261
+ walk(current.right);
262
+ return;
263
+ case AST_NODE_TYPES.Identifier:
264
+ case AST_NODE_TYPES.MemberExpression: {
265
+ const target = referenced(current);
266
+ if (target) {
267
+ walk(target);
268
+ return;
269
+ }
270
+ break;
271
+ }
272
+ case AST_NODE_TYPES.CallExpression:
273
+ if (didWalkCall(current))
274
+ return;
275
+ break;
276
+ case AST_NODE_TYPES.ArrowFunctionExpression:
277
+ case AST_NODE_TYPES.FunctionExpression:
278
+ case AST_NODE_TYPES.FunctionDeclaration:
279
+ if (!returns)
280
+ break;
281
+ walkFunction(current);
282
+ return;
283
+ default:
284
+ break;
285
+ }
286
+ out.push(current);
287
+ };
288
+ walk(node);
289
+ return out;
290
+ };
@@ -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,91 @@
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
+ * A callable or constructable type is truthy. Any other object type is only
26
+ * as good as what the falsy primitives do against it: `{}`, `Object` and
27
+ * `{ length: number }` all accept `""`, `0` or `false`.
28
+ */
29
+ const isTruthyObject = (type, checker) => {
30
+ if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0)
31
+ return true;
32
+ if (!checker)
33
+ return false;
34
+ return [checker.getStringLiteralType(""), checker.getNumberLiteralType(0), checker.getFalseType()].every((falsy) => !checker.isTypeAssignableTo(falsy, type));
35
+ };
36
+ /**
37
+ * Whether every value of this type is truthy, `undefined` aside.
38
+ *
39
+ * Safe: `true`, `symbol`, `object`, a non-falsy literal, `undefined`, `void`,
40
+ * `never`, and an object type that is callable or to which none of `""`, `0`
41
+ * and `false` is assignable. Unions and intersections are read part by part,
42
+ * unresolved types through their constraint. Everything else is not claimed.
43
+ *
44
+ * @param checker Needed to resolve a constraint and to ask whether a falsy
45
+ * primitive fits an object type; without one only a callable object is claimed.
46
+ */
47
+ export const isSameAsBangBang = (type, checker) => {
48
+ const isEach = (part) => isSameAsBangBang(part, checker);
49
+ if (type.isUnion() || type.isIntersection())
50
+ return type.types.every(isEach);
51
+ if (type.flags & UNRESOLVED) {
52
+ const constraint = type.isTypeParameter() ? type.getConstraint() : checker?.getBaseConstraintOfType(type);
53
+ // No constraint means no bound on what it holds.
54
+ return !!constraint && isEach(constraint);
55
+ }
56
+ if (type.flags & (TypeFlags.Undefined | TypeFlags.Void | TypeFlags.Never)) {
57
+ return true;
58
+ }
59
+ if (type.flags & TypeFlags.BooleanLiteral) {
60
+ return type.intrinsicName === "true";
61
+ }
62
+ if (type.flags & (TypeFlags.ESSymbolLike | TypeFlags.NonPrimitive)) {
63
+ return true;
64
+ }
65
+ if (type.flags & TypeFlags.Object)
66
+ return isTruthyObject(type, checker);
67
+ // A literal is as truthy as its value: only `""`, `0` and `0n` are falsy.
68
+ if (type.isStringLiteral())
69
+ return type.value !== "";
70
+ if (type.isNumberLiteral())
71
+ return type.value !== 0;
72
+ if (type.flags & TypeFlags.BigIntLiteral) {
73
+ return type.value.base10Value !== "0";
74
+ }
75
+ // A primitive, `any`, `unknown`, or a flag not named above.
76
+ return false;
77
+ };
78
+ const isUndefined = (side) => side.type === AST_NODE_TYPES.Identifier && side.name === "undefined";
79
+ /** `x !== undefined` / `undefined === x`; `present` is what the check wants. */
80
+ export const undefinedComparison = (node) => {
81
+ if (node?.type !== AST_NODE_TYPES.BinaryExpression)
82
+ return undefined;
83
+ if (node.operator !== "!==" && node.operator !== "===")
84
+ return undefined;
85
+ const isPresent = node.operator === "!==";
86
+ if (isUndefined(node.left) && !isUndefined(node.right))
87
+ return { value: node.right, isPresent };
88
+ if (isUndefined(node.right) && !isUndefined(node.left))
89
+ return { value: node.left, isPresent };
90
+ return undefined;
91
+ };
@@ -1,15 +1,18 @@
1
1
  import expo from 'eslint-plugin-expo';
2
+ import reactDoctor from 'eslint-plugin-react-doctor';
2
3
  import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
3
4
  import { createNodeResolver } from 'eslint-plugin-import-x';
4
5
  import globals from 'globals';
5
6
  import noSecrets from 'eslint-plugin-no-secrets';
6
7
  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';
8
+ import { CODE_FILES, NUMBER_GLOBAL_RESTRICTIONS, raiseWarnings, REACT_BOOLEAN_NAME_OPTIONS, REACT_RESTRICTED_SYNTAX, reactConfigs, TEST_FILES, } from './shared.js';
8
9
  const A11Y = '@hypersolid-dev/react-native-a11y';
9
- // Derived instead of hardcoded: the frozen `confusing-browser-globals` list (last released 2021) misses `document`,
10
- // `localStorage` and `getComputedStyle`, and bans `self`, which React Native defines. `globals` is maintained,
11
- // so new browser APIs are picked up without editing this file.
12
- // Globals Hermes provides that `globals['react-native']` does not list yet; remove a name once `globals` adds it.
10
+ /**
11
+ * Derived instead of hardcoded: the frozen `confusing-browser-globals` list (last released 2021) misses `document`,
12
+ * `localStorage` and `getComputedStyle`, and bans `self`, which React Native defines. `globals` is maintained,
13
+ * so new browser APIs are picked up without editing this file.
14
+ * Globals Hermes provides that `globals['react-native']` does not list yet; remove a name once `globals` adds it.
15
+ */
13
16
  const HERMES_GLOBALS = new Set(['atob', 'btoa', 'TextEncoder']);
14
17
  const BROWSER_GLOBALS = Object.keys(globals.browser).filter((name) => !(name in globals['react-native']) && !HERMES_GLOBALS.has(name));
15
18
  // High-entropy strings in Expo and native config that are identifiers, not credentials.
@@ -21,8 +24,10 @@ const NO_SECRETS_IGNORE = [
21
24
  '^[A-Z][A-Za-z0-9]*\\/[A-Z][A-Za-z0-9]*$', // i18n `Namespace/Key` paths
22
25
  '^data:', // Lottie and embedded images
23
26
  ];
24
- // Metro picks `Button.ios.tsx` for `./Button` on iOS; the resolvers must try the same platform files, or
25
- // `import-x/no-unresolved` flags every platform-split module. Platform files first, as Metro resolves them.
27
+ /**
28
+ * Metro picks `Button.ios.tsx` for `./Button` on iOS; the resolvers must try the same platform files, or
29
+ * `import-x/no-unresolved` flags every platform-split module. Platform files first, as Metro resolves them.
30
+ */
26
31
  const RESOLVE_EXTENSIONS = ['.ios', '.android', '.native', '.web', ''].flatMap((platform) => ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.json'].map((extension) => `${platform}${extension}`));
27
32
  // Metro's default asset extensions plus Expo's additions, and json (source or asset); from eslint-config-expo.
28
33
  const METRO_ASSET_EXTENSIONS = [
@@ -70,9 +75,11 @@ export default function reactNative() {
70
75
  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
76
  })),
72
77
  ],
73
- // Unicorn rules that push toward APIs the React Native runtime lacks (Hermes in RN 0.85 and RN's fetch/Blob/URL
74
- // polyfills); the suggested fix crashes at runtime. Re-check when RN bumps Hermes.
75
- // `toSorted` is missing from Hermes. Bad: `values.toSorted(compare)` suggested for `values.sort(compare)`.
78
+ /**
79
+ * Unicorn rules that push toward APIs the React Native runtime lacks (Hermes in RN 0.85 and RN's fetch/Blob/URL
80
+ * polyfills); the suggested fix crashes at runtime. Re-check when RN bumps Hermes.
81
+ * `toSorted` is missing from Hermes. Bad: `values.toSorted(compare)` suggested for `values.sort(compare)`.
82
+ */
76
83
  'unicorn/no-array-sort': 'off',
77
84
  // Iterator helpers are missing from Hermes. Bad: `map.values().map(double).toArray()`.
78
85
  'unicorn/prefer-iterator-helpers': 'off',
@@ -99,6 +106,78 @@ export default function reactNative() {
99
106
  ],
100
107
  // High-entropy strings that look like credentials, minus Expo and native identifiers. Bad: `const token = 'ghp_Zx8kQ2vLm9…'`.
101
108
  'no-secrets/no-secrets': ['error', { ignoreContent: NO_SECRETS_IGNORE }],
109
+ // The React entries, plus `pointerEvents` as a prop: it belongs in the style. Bad: `<View pointerEvents="none" />`.
110
+ 'no-restricted-syntax': [
111
+ 'error',
112
+ ...REACT_RESTRICTED_SYNTAX,
113
+ {
114
+ selector: ":matches(JSXAttribute[name.name='pointerEvents'], JSXSpreadAttribute > ObjectExpression > Property[key.name='pointerEvents'])",
115
+ message: "Put `pointerEvents` in the style (`pointerEvents: 'none'`); the prop is deprecated.",
116
+ },
117
+ ],
118
+ // Deprecated or JS-thread APIs from `react-native`. Bad: `InteractionManager.runAfterInteractions(load)`, `import { Animated } from 'react-native'`.
119
+ '@typescript-eslint/no-restricted-imports': [
120
+ 'error',
121
+ {
122
+ paths: [
123
+ {
124
+ name: 'react-native',
125
+ importNames: ['InteractionManager'],
126
+ message: 'Deprecated in the New Architecture. Use `requestIdleCallback` or `startTransition`.',
127
+ allowTypeImports: true,
128
+ },
129
+ {
130
+ name: 'react-native',
131
+ importNames: ['Animated'],
132
+ message: 'Use react-native-reanimated, which runs animations on the UI thread; `Animated` runs them on the JS thread and needs `useNativeDriver`.',
133
+ allowTypeImports: true,
134
+ },
135
+ ],
136
+ },
137
+ ],
138
+ // `testID` is lowercase kebab-case, so test selectors match exactly. Bad: `testID="Header.Title"`.
139
+ '@hypersolid-dev/test-id-format': 'error',
140
+ // A WebView allows every origin and filters each navigation in `onShouldStartLoadWithRequest`. Bad: `<WebView source={{ uri }} />`.
141
+ '@hypersolid-dev/webview-allow-list': 'error',
142
+ },
143
+ },
144
+ {
145
+ // Reusable components only: a screen is one instance, so a written id is fine there.
146
+ name: '@hypersolid-dev/react-native/components',
147
+ files: ['**/components/**/*.tsx'],
148
+ ignores: TEST_FILES,
149
+ rules: {
150
+ // A component's `testID` comes from its props, so two instances stay apart. Bad: `<View testID="card" />` inside `Card`.
151
+ '@hypersolid-dev/test-id-from-props': 'error',
152
+ // The `testID` prop defaults to the component's kebab-case name. Bad: `({ testID }: Props)` in `Card` (use `testID = 'card'`).
153
+ '@hypersolid-dev/test-id-default': 'error',
154
+ },
155
+ },
156
+ {
157
+ // React Doctor's React Native rules, as used in the ESPN Goal pilot.
158
+ name: '@hypersolid-dev/react-native/react-doctor',
159
+ files: CODE_FILES,
160
+ ignores: TEST_FILES,
161
+ plugins: { 'react-doctor': reactDoctor },
162
+ rules: {
163
+ // A state update per scroll event re-renders mid-gesture; track position in a shared value. Bad: `onScroll={(event) => setOffset(event.nativeEvent.contentOffset.y)}`.
164
+ 'react-doctor/rn-no-scroll-state': 'error',
165
+ // A reaction that only copies one value into another is a `useDerivedValue`. Bad: `useAnimatedReaction(() => x.value, (value) => { y.value = value; })`.
166
+ 'react-doctor/rn-animation-reaction-as-derived': 'error',
167
+ // A string outside `<Text>` crashes on native. Bad: `<View>Hello</View>`.
168
+ 'react-doctor/rn-no-raw-text': 'error',
169
+ // A list keys rows from `keyExtractor`; a `key` on the row is ignored. Bad: `renderItem={({ item }) => <Row key={item.id} />}`.
170
+ 'react-doctor/rn-no-renderitem-key': 'error',
171
+ // A press animation set from `onPressIn` runs on the JS thread. Bad: `onPressIn={() => { scale.value = 0.9; }}`.
172
+ 'react-doctor/rn-pressable-shared-value-mutation': 'error',
173
+ // `PanResponder` runs on the JS thread; `Gesture.Pan()` runs on the UI thread. Bad: `PanResponder.create({ … })`.
174
+ 'react-doctor/rn-no-panresponder': 'error',
175
+ // `Dimensions.get()` reads the size once and misses rotation and split screen. Bad: `const { width } = Dimensions.get('window')` (use `useWindowDimensions`).
176
+ 'react-doctor/rn-no-dimensions-get': 'error',
177
+ // `react-native/Libraries/…` paths are private and move on upgrade. Bad: `import … from 'react-native/Libraries/Image/resolveAssetSource'`.
178
+ 'react-doctor/rn-no-deep-imports': 'error',
179
+ // Unmaintained Expo-era packages each have a successor. Bad: `import { Audio } from 'expo-av'` (use `expo-audio`).
180
+ 'react-doctor/rn-no-legacy-expo-packages': 'error',
102
181
  },
103
182
  },
104
183
  {
@@ -160,8 +239,10 @@ export default function reactNative() {
160
239
  [`${A11Y}/no-unnamed-interactive`]: 'error',
161
240
  // Images opt out of iOS Smart Invert or render as a negative. Bad: `<Image source={source} accessibilityLabel="A cat" />`.
162
241
  [`${A11Y}/require-invert-colors-opt-out`]: 'error',
163
- // Off: a sheet is whatever a project calls one, so the rule has no defaults. Enable it with the project's sheet components:
164
- // `['error', { additionalComponents: ['BottomSheet'] }]`. Bad then: `<BottomSheet><Text>Body</Text></BottomSheet>`.
242
+ /**
243
+ * Off: a sheet is whatever a project calls one, so the rule has no defaults. Enable it with the project's sheet components:
244
+ * `['error', { additionalComponents: ['BottomSheet'] }]`. Bad then: `<BottomSheet><Text>Body</Text></BottomSheet>`.
245
+ */
165
246
  [`${A11Y}/require-modal-a11y-on-sheets`]: 'off',
166
247
  },
167
248
  },