@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,182 @@
1
+ import path from 'node:path';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+ import createRule from '../utils/create-rule.js';
4
+ /**
5
+ * A component's `testID` prop defaults to the component's own name, kebab-cased.
6
+ *
7
+ * Undefaulted, `testID` exists only when a caller passes one, so every derived
8
+ * child needs an `undefined` guard. With a default it is a string inside the
9
+ * component and a child is `` `${testID}-photo` ``. The default is a floor, not
10
+ * a guarantee of uniqueness.
11
+ *
12
+ * Only the file's own component is asked: `Crest` in `Crest.tsx` (or in
13
+ * `Crest/index.tsx`), through `memo`/`forwardRef`, plus an anonymous default
14
+ * export. A platform suffix (`Crest.ios.tsx`) is dropped. A file whose name is
15
+ * not PascalCase (`home.tsx`, a route) is skipped.
16
+ *
17
+ * Reported: `missing` (destructured with no default, in the parameter or as
18
+ * `const { testID } = props`), `wrong` (a default that is not the kebab-cased
19
+ * name), `readsProp` (`props.testID` read off the props object or its rest).
20
+ *
21
+ * Limits: a `testID` passed through `{...props}`, a copied props object, and a
22
+ * component whose name does not match its file are not seen.
23
+ */
24
+ const HOC_NAMES = new Set(['memo', 'forwardRef']);
25
+ const FUNCTION_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
26
+ const TS_WRAPPERS = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression']);
27
+ // The platform suffix on a source file names the build, not the component.
28
+ const PLATFORM_PATTERN = /\.(?:native|ios|android|androidtv|tv|tvos|web)$/;
29
+ const EXTENSION_PATTERN = /\.[jt]sx?$/;
30
+ const PASCAL_CASE_PATTERN = /^[A-Z]/;
31
+ const LOWER_TO_UPPER_PATTERN = /(?<=[a-z0-9])(?=[A-Z])/g;
32
+ const ACRONYM_END_PATTERN = /(?<=[A-Z])(?=[A-Z][a-z])/g;
33
+ /** `CompetitionLogo` -> `competition-logo`, `AdSlotURLBar` -> `ad-slot-url-bar`. */
34
+ const kebab = (name) => name.replaceAll(LOWER_TO_UPPER_PATTERN, '-').replaceAll(ACRONYM_END_PATTERN, '-').toLowerCase();
35
+ /** The component a file is named for: its basename, or its folder's for an `index` file. */
36
+ const componentName = (filename) => {
37
+ const name = path.basename(filename).replace(EXTENSION_PATTERN, '').replace(PLATFORM_PATTERN, '');
38
+ return name === 'index' ? path.basename(path.dirname(filename)) : name;
39
+ };
40
+ const isHoc = (callee) => {
41
+ if (callee.type === AST_NODE_TYPES.Identifier)
42
+ return HOC_NAMES.has(callee.name);
43
+ return callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed && HOC_NAMES.has(callee.property.name);
44
+ };
45
+ const isFunction = (node) => Boolean(node && FUNCTION_TYPES.has(node.type));
46
+ /** Through `memo(…)`, `forwardRef(…)` and type assertions, to the component inside. */
47
+ const unwrapComponent = (node) => {
48
+ let current = node ?? undefined;
49
+ while (current) {
50
+ if (TS_WRAPPERS.has(current.type))
51
+ current = current.expression;
52
+ else if (current.type === AST_NODE_TYPES.CallExpression && isHoc(current.callee))
53
+ current = current.arguments[0];
54
+ else
55
+ return current;
56
+ }
57
+ return current;
58
+ };
59
+ /** What a function is held by, out through the calls and assertions it is declared behind. */
60
+ const heldBy = (fn) => {
61
+ let held = fn.parent;
62
+ while (held && (held.type === AST_NODE_TYPES.CallExpression || TS_WRAPPERS.has(held.type)))
63
+ held = held.parent;
64
+ return held;
65
+ };
66
+ /** Whether a function is the file's own component: named `component`, or an anonymous default export. */
67
+ const isComponent = (fn, component) => {
68
+ if (fn.type === AST_NODE_TYPES.FunctionDeclaration)
69
+ return !fn.id || fn.id.name === component;
70
+ const held = heldBy(fn);
71
+ if (held?.type === AST_NODE_TYPES.ExportDefaultDeclaration)
72
+ return true;
73
+ return held?.type === AST_NODE_TYPES.VariableDeclarator && held.id.type === AST_NODE_TYPES.Identifier && held.id.name === component;
74
+ };
75
+ /** The `testID` property of a destructured parameter or declarator. */
76
+ const testIdProperty = (pattern) => {
77
+ if (pattern?.type !== AST_NODE_TYPES.ObjectPattern)
78
+ return undefined;
79
+ return pattern.properties.find((property) => property.type === AST_NODE_TYPES.Property && !property.computed && property.key.type === AST_NODE_TYPES.Identifier && property.key.name === 'testID');
80
+ };
81
+ /** The names a component's props object goes by: the first parameter, or the rest of a destructured one without `testID`. */
82
+ const propsNames = (fn) => {
83
+ const first = fn.params.at(0);
84
+ if (first?.type === AST_NODE_TYPES.Identifier)
85
+ return [first.name];
86
+ if (first?.type !== AST_NODE_TYPES.ObjectPattern || testIdProperty(first))
87
+ return [];
88
+ const rest = first.properties.find((property) => property.type === AST_NODE_TYPES.RestElement);
89
+ return rest?.argument.type === AST_NODE_TYPES.Identifier ? [rest.argument.name] : [];
90
+ };
91
+ /** The file's own component that `node` sits in, however deep in callbacks. */
92
+ const ownComponentAround = (node, component) => {
93
+ for (let current = node.parent; current; current = current.parent) {
94
+ if (isFunction(current) && isComponent(current, component))
95
+ return current;
96
+ }
97
+ return undefined;
98
+ };
99
+ const testIdDefault = createRule({
100
+ name: 'test-id-default',
101
+ meta: {
102
+ defaultOptions: [],
103
+ type: 'problem',
104
+ docs: { description: "A component's `testID` prop defaults to its own name in kebab-case." },
105
+ schema: [],
106
+ messages: {
107
+ missing: 'Default this to `"{{ expected }}"`: an undefaulted `testID` is a handle that exists only when a caller passes one, so every derived child needs an `undefined` guard beside it. With the default, `testID` is a string inside the component and a child is `` `${testID}-photo` ``.',
108
+ readsProp: 'Destructure `testID` with the default `"{{ expected }}"` instead of reading `{{ object }}.testID`: an undefaulted `testID` is a handle that exists only when a caller passes one, and a `??` at the use site is that guard written one place at a time.',
109
+ wrong: 'Default this to `"{{ expected }}"`, not `{{ actual }}`: the default is the component\'s own name in kebab-case, so a reader can predict the id without opening the file.',
110
+ },
111
+ },
112
+ create: (context) => {
113
+ const component = componentName(context.filename);
114
+ // The file's own component only; anything else in here is private to it.
115
+ if (!PASCAL_CASE_PATTERN.test(component))
116
+ return {};
117
+ const expected = kebab(component);
118
+ const check = (property) => {
119
+ if (!property)
120
+ return;
121
+ if (property.value.type !== AST_NODE_TYPES.AssignmentPattern) {
122
+ context.report({ node: property, messageId: 'missing', data: { expected } });
123
+ return;
124
+ }
125
+ const { right } = property.value;
126
+ // A substitution-free template is a string written the long way, as `test-id-format` accepts.
127
+ let written;
128
+ if (right.type === AST_NODE_TYPES.Literal)
129
+ written = String(right.value);
130
+ else if (right.type === AST_NODE_TYPES.TemplateLiteral && right.expressions.length === 0)
131
+ written = right.quasis[0]?.value.cooked ?? undefined;
132
+ if (written === expected)
133
+ return;
134
+ context.report({
135
+ node: right,
136
+ messageId: 'wrong',
137
+ data: { expected, actual: written === undefined ? 'this' : `"${written}"` },
138
+ });
139
+ };
140
+ const checkFunction = (node, name) => {
141
+ if (isFunction(node) && name === component)
142
+ check(testIdProperty(node.params[0]));
143
+ };
144
+ return {
145
+ VariableDeclarator: (node) => {
146
+ // `const { testID } = props` inside the file's own component reads the same prop.
147
+ if (node.id.type === AST_NODE_TYPES.ObjectPattern && node.init?.type === AST_NODE_TYPES.Identifier) {
148
+ const fn = ownComponentAround(node, component);
149
+ if (fn && propsNames(fn).includes(node.init.name))
150
+ check(testIdProperty(node.id));
151
+ return;
152
+ }
153
+ if (node.id.type === AST_NODE_TYPES.Identifier)
154
+ checkFunction(unwrapComponent(node.init), node.id.name);
155
+ },
156
+ FunctionDeclaration: (node) => {
157
+ if (node.id)
158
+ checkFunction(node, node.id.name);
159
+ else if (node.parent.type === AST_NODE_TYPES.ExportDefaultDeclaration)
160
+ checkFunction(node, component);
161
+ },
162
+ // `export default memo(({ testID }) => …)`: the anonymous component.
163
+ ExportDefaultDeclaration: (node) => {
164
+ const init = unwrapComponent(node.declaration);
165
+ if (init?.type !== AST_NODE_TYPES.FunctionDeclaration)
166
+ checkFunction(init, component);
167
+ },
168
+ // `props.testID` read inside the component, with no default anywhere.
169
+ MemberExpression: (node) => {
170
+ if (node.computed || node.property.type !== AST_NODE_TYPES.Identifier || node.property.name !== 'testID')
171
+ return;
172
+ if (node.object.type !== AST_NODE_TYPES.Identifier)
173
+ return;
174
+ const fn = ownComponentAround(node, component);
175
+ if (!fn || !propsNames(fn).includes(node.object.name))
176
+ return;
177
+ context.report({ node, messageId: 'readsProp', data: { expected, object: node.object.name } });
178
+ },
179
+ };
180
+ },
181
+ });
182
+ export default testIdDefault;
@@ -0,0 +1,4 @@
1
+ declare const testIdFormat: import("@typescript-eslint/utils/ts-eslint").RuleModule<"format", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
4
+ export default testIdFormat;
@@ -0,0 +1,118 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import createRule from '../utils/create-rule.js';
3
+ import { leavesOf } from '../utils/expressions.js';
4
+ /**
5
+ * A `testID` written out in full is lowercase kebab-case: `crest`,
6
+ * `vote-input`, `top-scorers-card-home-crest`.
7
+ *
8
+ * One alphabet, one separator. Test tools match ids case-sensitively, and
9
+ * Maestro matches `id:` as a regular expression where `.` is a wildcard, so a
10
+ * dash is the one separator that survives. Depth is a dash too, which lets a
11
+ * component derive a child (`` `${testID}-photo` ``) the same way at every level.
12
+ *
13
+ * Reported (`format`, with a suggested spelling): a written string in a JSX
14
+ * `testID` attribute, a `testID` default in a destructuring, or a `testID:`
15
+ * property of an object, read through ternaries, logicals, type wrappers,
16
+ * `Platform.select` and local `const`s. The written pieces of an interpolated id
17
+ * are checked as fragments, which may begin or end on the joining dash.
18
+ *
19
+ * Limits: a runtime value (`slug(name)`, `"Home" + "_Crest"`) is not read.
20
+ */
21
+ const FORMAT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
22
+ // A piece between substitutions: may start or end on a join dash, or be only dashes.
23
+ const FRAGMENT_PATTERN = /^(?:-+|-?[a-z0-9]+(?:-[a-z0-9]+)*-?)$/;
24
+ const LOWER_TO_UPPER_PATTERN = /(?<=[a-z0-9])(?=[A-Z])/g;
25
+ const ACRONYM_END_PATTERN = /(?<=[A-Z])(?=[A-Z][a-z])/g;
26
+ const NON_ALPHANUMERIC_RUN_PATTERN = /[^A-Za-z0-9]+/g;
27
+ const EDGE_DASHES_PATTERN = /^-+|-+$/g;
28
+ const LEADING_SEPARATOR_PATTERN = /^[^A-Za-z0-9]/;
29
+ const TRAILING_SEPARATOR_PATTERN = /[^A-Za-z0-9]$/;
30
+ const TEST_ID = 'testID';
31
+ /** `Match.playerItem_photo` -> `match-player-item-photo`. */
32
+ const kebab = (value) => value
33
+ .replaceAll(LOWER_TO_UPPER_PATTERN, '-')
34
+ .replaceAll(ACRONYM_END_PATTERN, '-')
35
+ .replaceAll(NON_ALPHANUMERIC_RUN_PATTERN, '-')
36
+ .replaceAll(EDGE_DASHES_PATTERN, '')
37
+ .toLowerCase();
38
+ /** A piece between substitutions keeps a separator where it meets one: `_photo` becomes `-photo`. */
39
+ const kebabFragment = (value) => {
40
+ const core = kebab(value);
41
+ const lead = LEADING_SEPARATOR_PATTERN.test(value) ? '-' : '';
42
+ const trail = TRAILING_SEPARATOR_PATTERN.test(value) ? '-' : '';
43
+ return core ? `${lead}${core}${trail}` : '-';
44
+ };
45
+ /** Whether `inner` is written inside `outer`, rather than elsewhere in the file. */
46
+ const isInside = (outer, inner) => inner.range[0] >= outer.range[0] && inner.range[1] <= outer.range[1];
47
+ /** The written string an expression is, or `undefined` when it is not one. */
48
+ const writtenString = (node) => {
49
+ if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string')
50
+ return node.value;
51
+ // A template with nothing substituted is a literal written the long way.
52
+ if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0)
53
+ return node.quasis[0]?.value.cooked ?? undefined;
54
+ return undefined;
55
+ };
56
+ const testIdFormat = createRule({
57
+ name: 'test-id-format',
58
+ meta: {
59
+ defaultOptions: [],
60
+ type: 'problem',
61
+ docs: { description: 'A written `testID` is lowercase kebab-case.' },
62
+ schema: [],
63
+ messages: {
64
+ format: 'Write this `testID` as `{{ suggestion }}`, not "{{ value }}": an id is lowercase kebab-case throughout, depth included. A dot is a wildcard in Maestro\'s `id:` matcher, and a capital is one shift key away from a flake — so a child derives with a dash, `` `${testID}-remote-image` ``, at every level.',
65
+ },
66
+ },
67
+ create: (context) => {
68
+ // Reported on the string when it sits inside the expression, else on the expression a name led from.
69
+ const check = (expression, leaf) => {
70
+ const value = writtenString(leaf);
71
+ if (value === undefined || FORMAT_PATTERN.test(value))
72
+ return;
73
+ context.report({
74
+ node: isInside(expression, leaf) ? leaf : expression,
75
+ messageId: 'format',
76
+ data: { value, suggestion: kebab(value) },
77
+ });
78
+ };
79
+ const checkFragments = (expression, leaf) => {
80
+ if (leaf.type !== AST_NODE_TYPES.TemplateLiteral || leaf.expressions.length === 0)
81
+ return;
82
+ for (const quasi of leaf.quasis) {
83
+ const text = quasi.value.cooked;
84
+ if (!text || FRAGMENT_PATTERN.test(text))
85
+ continue;
86
+ context.report({
87
+ node: isInside(expression, quasi) ? quasi : expression,
88
+ messageId: 'format',
89
+ data: { value: text, suggestion: kebabFragment(text) },
90
+ });
91
+ }
92
+ };
93
+ const checkExpression = (expression) => {
94
+ for (const leaf of leavesOf(context, expression)) {
95
+ check(expression, leaf);
96
+ checkFragments(expression, leaf);
97
+ }
98
+ };
99
+ return {
100
+ JSXAttribute: (node) => {
101
+ if (node.name.type !== AST_NODE_TYPES.JSXIdentifier || node.name.name !== TEST_ID)
102
+ return;
103
+ const { value } = node;
104
+ if (value?.type === AST_NODE_TYPES.Literal)
105
+ check(value, value);
106
+ if (value?.type === AST_NODE_TYPES.JSXExpressionContainer)
107
+ checkExpression(value.expression);
108
+ },
109
+ // A `testID` default, and a `testID:` in an options or fixture object.
110
+ Property: (node) => {
111
+ if (node.computed || node.key.type !== AST_NODE_TYPES.Identifier || node.key.name !== TEST_ID)
112
+ return;
113
+ checkExpression(node.value.type === AST_NODE_TYPES.AssignmentPattern ? node.value.right : node.value);
114
+ },
115
+ };
116
+ },
117
+ });
118
+ export default testIdFormat;
@@ -0,0 +1,4 @@
1
+ declare const testIdFromProps: import("@typescript-eslint/utils/ts-eslint").RuleModule<"static" | "prefixed", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
4
+ export default testIdFromProps;
@@ -0,0 +1,90 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import createRule from '../utils/create-rule.js';
3
+ import { leavesOf } from '../utils/expressions.js';
4
+ /**
5
+ * A component's `testID` comes from its props, never a string it writes itself.
6
+ *
7
+ * A static id is the same string on every instance, so two in one tree become
8
+ * one ambiguous query, and on the root it silently wins over the id a parent
9
+ * passes. The root takes `testID`; anything below derives with a template.
10
+ *
11
+ * Reported on a JSX `testID` attribute:
12
+ *
13
+ * - `static`: a string literal, a substitution-free template, or a local name
14
+ * that resolves to one.
15
+ * - `prefixed`: a template that interpolates but opens with written text.
16
+ *
17
+ * Accepted: a derived value however it is built (a template starting with a
18
+ * substitution, a call, a member read, a conditional or logical with a name on
19
+ * either side, a parameter, an import). The rule does not prove a name is a prop.
20
+ *
21
+ * Limits: an imported constant, a call returning a fixed string, a ternary with
22
+ * a prop on one side and a `let` all pass; only the `testID` attribute is read.
23
+ */
24
+ const TEST_ID = 'testID';
25
+ /** A value spelled out where it stands, rather than reached for. */
26
+ const isWritten = (node) => (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') || (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0);
27
+ /** Whether an expression reaches outside itself for its value. */
28
+ const isReadingAName = (context, node) => {
29
+ switch (node.type) {
30
+ // A local name bound to a written string is that string; anything unresolved is trusted.
31
+ case AST_NODE_TYPES.Identifier:
32
+ case AST_NODE_TYPES.MemberExpression: {
33
+ const leaves = leavesOf(context, node);
34
+ return leaves.length === 0 || !leaves.every(isWritten);
35
+ }
36
+ case AST_NODE_TYPES.CallExpression:
37
+ return true;
38
+ // The id has to start from something a caller gave: `${testID}-row`.
39
+ case AST_NODE_TYPES.TemplateLiteral:
40
+ return node.expressions.length > 0 && node.quasis[0]?.value.cooked === '';
41
+ case AST_NODE_TYPES.ConditionalExpression:
42
+ return isReadingAName(context, node.consequent) || isReadingAName(context, node.alternate);
43
+ case AST_NODE_TYPES.LogicalExpression:
44
+ return isReadingAName(context, node.left) || isReadingAName(context, node.right);
45
+ case AST_NODE_TYPES.TSAsExpression:
46
+ case AST_NODE_TYPES.TSSatisfiesExpression:
47
+ case AST_NODE_TYPES.TSNonNullExpression:
48
+ return isReadingAName(context, node.expression);
49
+ default:
50
+ return false;
51
+ }
52
+ };
53
+ /** How to name the offending id: a name is followed to the string behind it. */
54
+ const writtenValue = (context, node) => {
55
+ if (node?.type === AST_NODE_TYPES.Literal && typeof node.value === 'string')
56
+ return `"${node.value}"`;
57
+ const strings = (node ? leavesOf(context, node) : [])
58
+ .filter(isWritten)
59
+ .map((leaf) => (leaf.type === AST_NODE_TYPES.Literal ? leaf.value : leaf.quasis[0]?.value.cooked));
60
+ return strings.length === 1 ? `"${String(strings[0])}"` : 'a fixed value';
61
+ };
62
+ const testIdFromProps = createRule({
63
+ name: 'test-id-from-props',
64
+ meta: {
65
+ defaultOptions: [],
66
+ type: 'problem',
67
+ docs: { description: "A component's `testID` comes from its props, never a string it writes itself." },
68
+ schema: [],
69
+ messages: {
70
+ static: 'Derive this `testID` from a prop instead of writing {{ value }}: a fixed id is the same string on every instance, so two of these in one tree become one ambiguous query and a caller cannot tell them apart. The root takes `testID`, which every component defaults to its own name, and a node below it suffixes that.',
71
+ prefixed: 'Start this `testID` from the `testID` prop rather than the written "{{ prefix }}": what follows it may differ per instance, but the prefix spells out a name the prop already carries, so a caller cannot scope or rename it. Write `` `${testID}-…` ``.',
72
+ },
73
+ },
74
+ create: (context) => ({
75
+ JSXAttribute: (node) => {
76
+ if (node.name.type !== AST_NODE_TYPES.JSXIdentifier || node.name.name !== TEST_ID)
77
+ return;
78
+ const { value } = node;
79
+ const expression = value?.type === AST_NODE_TYPES.JSXExpressionContainer ? value.expression : (value ?? undefined);
80
+ if (value?.type === AST_NODE_TYPES.JSXExpressionContainer && isReadingAName(context, value.expression))
81
+ return;
82
+ if (expression?.type === AST_NODE_TYPES.TemplateLiteral && expression.expressions.length > 0) {
83
+ context.report({ node, messageId: 'prefixed', data: { prefix: expression.quasis[0]?.value.cooked } });
84
+ return;
85
+ }
86
+ context.report({ node, messageId: 'static', data: { value: writtenValue(context, expression) } });
87
+ },
88
+ }),
89
+ });
90
+ export default testIdFromProps;
@@ -0,0 +1,5 @@
1
+ import { ESLintUtils } from "@typescript-eslint/utils";
2
+ declare const undefinedCheck: ESLintUtils.RuleModule<"redundant", [], unknown, ESLintUtils.RuleListener> & {
3
+ name: string;
4
+ };
5
+ export default undefinedCheck;
@@ -0,0 +1,64 @@
1
+ import { AST_NODE_TYPES, ESLintUtils } from "@typescript-eslint/utils";
2
+ import createRule from "../utils/create-rule.js";
3
+ import { isSameAsBangBang, undefinedComparison } from "../utils/types.js";
4
+ /** The slots where a value is being asked whether it is there. */
5
+ const isCondition = (node) => {
6
+ const { parent } = node;
7
+ if (!parent)
8
+ return false;
9
+ if (parent.type === AST_NODE_TYPES.IfStatement ||
10
+ parent.type === AST_NODE_TYPES.WhileStatement ||
11
+ parent.type === AST_NODE_TYPES.DoWhileStatement ||
12
+ parent.type === AST_NODE_TYPES.ConditionalExpression ||
13
+ parent.type === AST_NODE_TYPES.ForStatement) {
14
+ return parent.test === node;
15
+ }
16
+ /**
17
+ * A `&&` or `||` is a condition only where the whole expression is one:
18
+ * `const isReady = user !== undefined && loaded` is a value.
19
+ */
20
+ if (parent.type === AST_NODE_TYPES.LogicalExpression) {
21
+ return (parent.operator === "&&" || parent.operator === "||") && isCondition(parent);
22
+ }
23
+ return parent.type === AST_NODE_TYPES.UnaryExpression && parent.operator === "!";
24
+ };
25
+ const undefinedCheck = createRule({
26
+ name: "undefined-check",
27
+ meta: {
28
+ defaultOptions: [],
29
+ type: "suggestion",
30
+ docs: {
31
+ description: "Disallow a comparison against `undefined` that the value's own truthiness already makes.",
32
+ },
33
+ schema: [],
34
+ messages: {
35
+ redundant: "`{{ text }}` is `{{ suggestion }}` for this type: `undefined` is the only falsy value it admits, so the comparison draws no line. Write the value.",
36
+ },
37
+ },
38
+ create: (context) => {
39
+ const services = ESLintUtils.getParserServices(context, true);
40
+ if (!services.program)
41
+ return {};
42
+ const checker = services.program.getTypeChecker();
43
+ return {
44
+ BinaryExpression: (node) => {
45
+ if (!isCondition(node))
46
+ return;
47
+ const compared = undefinedComparison(node);
48
+ if (!compared)
49
+ return;
50
+ if (!isSameAsBangBang(services.getTypeAtLocation(compared.value), checker))
51
+ return;
52
+ const text = context.sourceCode.getText(compared.value);
53
+ // `=== undefined` is the negation, so it keeps its `!`.
54
+ const suggestion = compared.isPresent ? text : `!${text}`;
55
+ context.report({
56
+ node,
57
+ messageId: "redundant",
58
+ data: { text: context.sourceCode.getText(node), suggestion },
59
+ });
60
+ },
61
+ };
62
+ },
63
+ });
64
+ export default undefinedCheck;
@@ -0,0 +1,5 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ declare const webviewAllowList: TSESLint.RuleModule<"missingHandler" | "missingWhitelist" | "narrowWhitelist", [], unknown, TSESLint.RuleListener> & {
3
+ name: string;
4
+ };
5
+ export default webviewAllowList;
@@ -0,0 +1,105 @@
1
+ import { AST_NODE_TYPES, TSESLint } from '@typescript-eslint/utils';
2
+ import createRule from '../utils/create-rule.js';
3
+ import { unwrap } from '../utils/expressions.js';
4
+ /**
5
+ * A `react-native-webview` `WebView` sends every navigation through its
6
+ * `onShouldStartLoadWithRequest`, so that handler is the allow-list.
7
+ *
8
+ * The library checks `originWhitelist` first and hands a URL outside it to
9
+ * `Linking.openURL` without asking the handler. The default list is
10
+ * `["http://*", "https://*"]`, so a page's `tel:`, `sms:` or `intent:` link opens
11
+ * another app however strict the handler is. `["*"]` is the one value that
12
+ * leaves every decision to the handler.
13
+ *
14
+ * Reports a `WebView` without an always-present `onShouldStartLoadWithRequest`,
15
+ * without `originWhitelist`, or whose `originWhitelist` is not an inline array
16
+ * holding `"*"`.
17
+ *
18
+ * Limits: matched through its import from `react-native-webview` (default,
19
+ * named or namespace), so a re-export or wrapper escapes; a spread is not read.
20
+ */
21
+ const { DefinitionType } = TSESLint.Scope;
22
+ const SOURCE = 'react-native-webview';
23
+ const WEBVIEW = 'WebView';
24
+ const HANDLER = 'onShouldStartLoadWithRequest';
25
+ /** Whether a JSX name resolves to the library's `WebView` import. */
26
+ const isWebView = (context, name) => {
27
+ const identifier = name.type === AST_NODE_TYPES.JSXMemberExpression ? name.object : name;
28
+ if (identifier.type !== AST_NODE_TYPES.JSXIdentifier)
29
+ return false;
30
+ let scope = context.sourceCode.getScope(name);
31
+ while (scope && !scope.set.has(identifier.name))
32
+ scope = scope.upper;
33
+ const definition = scope?.set.get(identifier.name)?.defs[0];
34
+ if (definition?.type !== DefinitionType.ImportBinding || definition.parent.type !== AST_NODE_TYPES.ImportDeclaration)
35
+ return false;
36
+ if (definition.parent.source.value !== SOURCE)
37
+ return false;
38
+ const { node } = definition;
39
+ if (name.type === AST_NODE_TYPES.JSXMemberExpression)
40
+ return node.type === AST_NODE_TYPES.ImportNamespaceSpecifier && name.property.name === WEBVIEW;
41
+ if (node.type === AST_NODE_TYPES.ImportDefaultSpecifier)
42
+ return true;
43
+ if (node.type !== AST_NODE_TYPES.ImportSpecifier)
44
+ return false;
45
+ const imported = node.imported.type === AST_NODE_TYPES.Identifier ? node.imported.name : node.imported.value;
46
+ return imported === WEBVIEW;
47
+ };
48
+ /** `undefined`, `null`, or a branch that can be one: the prop is set and still absent. */
49
+ const canBeAbsent = (expression) => {
50
+ switch (expression.type) {
51
+ case AST_NODE_TYPES.JSXEmptyExpression:
52
+ return true;
53
+ case AST_NODE_TYPES.Identifier:
54
+ return expression.name === 'undefined';
55
+ case AST_NODE_TYPES.Literal:
56
+ return expression.value === null;
57
+ case AST_NODE_TYPES.ConditionalExpression:
58
+ return canBeAbsent(expression.consequent) || canBeAbsent(expression.alternate);
59
+ case AST_NODE_TYPES.LogicalExpression:
60
+ return expression.operator === '&&' || canBeAbsent(expression.right);
61
+ default:
62
+ return false;
63
+ }
64
+ };
65
+ const attribute = (opening, name) => opening.attributes.find((item) => item.type === AST_NODE_TYPES.JSXAttribute && item.name.name === name);
66
+ /** Whether the prop's value is an inline array literal holding the string `"*"`. */
67
+ const isHoldingWildcard = (property) => {
68
+ const expression = unwrap(property.value?.type === AST_NODE_TYPES.JSXExpressionContainer ? property.value.expression : undefined);
69
+ if (expression?.type !== AST_NODE_TYPES.ArrayExpression)
70
+ return false;
71
+ return expression.elements.some((element) => (element?.type === AST_NODE_TYPES.Literal && element.value === '*') ||
72
+ (element?.type === AST_NODE_TYPES.TemplateLiteral && element.expressions.length === 0 && element.quasis[0]?.value.cooked === '*'));
73
+ };
74
+ const webviewAllowList = createRule({
75
+ name: 'webview-allow-list',
76
+ meta: {
77
+ defaultOptions: [],
78
+ type: 'problem',
79
+ docs: {
80
+ description: 'A WebView allows every origin and decides each navigation in its load-request handler.',
81
+ },
82
+ schema: [],
83
+ messages: {
84
+ missingHandler: 'Give this WebView an always-present `{{ handler }}` that allows only the hosts and schemes it trusts; without one every navigation a page starts loads.',
85
+ missingWhitelist: 'Set `originWhitelist={["*"]}`: the default `["http://*", "https://*"]` sends a page\'s `tel:`, `sms:` or `intent:` link straight to `Linking.openURL`, past `{{ handler }}`.',
86
+ narrowWhitelist: 'Make `originWhitelist` the inline `["*"]`: a URL outside the list skips `{{ handler }}` and the library opens it in another app, so the handler is the only allow-list that holds.',
87
+ },
88
+ },
89
+ create: (context) => ({
90
+ JSXOpeningElement: (node) => {
91
+ if (!isWebView(context, node.name))
92
+ return;
93
+ const handler = attribute(node, HANDLER);
94
+ if (handler?.value?.type !== AST_NODE_TYPES.JSXExpressionContainer || canBeAbsent(handler.value.expression)) {
95
+ context.report({ node, messageId: 'missingHandler', data: { handler: HANDLER } });
96
+ }
97
+ const whitelist = attribute(node, 'originWhitelist');
98
+ if (!whitelist)
99
+ context.report({ node, messageId: 'missingWhitelist', data: { handler: HANDLER } });
100
+ else if (!isHoldingWildcard(whitelist))
101
+ context.report({ node: whitelist, messageId: 'narrowWhitelist', data: { handler: HANDLER } });
102
+ },
103
+ }),
104
+ });
105
+ export default webviewAllowList;
@@ -0,0 +1,15 @@
1
+ import { type TSESLint, type TSESTree } from "@typescript-eslint/utils";
2
+ /** A `/**` JSDoc block. ESLint strips the fences, so the value starts with `*`; a bare `/*` does not. */
3
+ export declare const isJsdoc: (comment: TSESTree.Comment) => boolean;
4
+ /**
5
+ * A directive's text is dictated by the tool that reads it, not by the author.
6
+ *
7
+ * What counts: `eslint-disable`/`-enable`/`-env` (and their `-next-line` forms),
8
+ * an `eslint rule-name: …` config comment, `prettier-…`, `@ts-…`, a `global`
9
+ * list of identifiers, and the coverage ignores `istanbul`, `c8` and `v8`.
10
+ * Prose that merely starts with one of those words is not: `// eslint is slow`
11
+ * and `// global cache, cleared on logout` are the author's. A `TODO` is prose.
12
+ */
13
+ export declare const DIRECTIVE_PATTERN: RegExp;
14
+ /** True when nothing but whitespace precedes the comment on its row. */
15
+ export declare const isRowStart: (sourceCode: Readonly<TSESLint.SourceCode>, comment: TSESTree.Comment) => boolean;
@@ -0,0 +1,15 @@
1
+ import { AST_TOKEN_TYPES } from "@typescript-eslint/utils";
2
+ /** A `/**` JSDoc block. ESLint strips the fences, so the value starts with `*`; a bare `/*` does not. */
3
+ export const isJsdoc = (comment) => comment.type === AST_TOKEN_TYPES.Block && comment.value.startsWith("*");
4
+ /**
5
+ * A directive's text is dictated by the tool that reads it, not by the author.
6
+ *
7
+ * What counts: `eslint-disable`/`-enable`/`-env` (and their `-next-line` forms),
8
+ * an `eslint rule-name: …` config comment, `prettier-…`, `@ts-…`, a `global`
9
+ * list of identifiers, and the coverage ignores `istanbul`, `c8` and `v8`.
10
+ * Prose that merely starts with one of those words is not: `// eslint is slow`
11
+ * and `// global cache, cleared on logout` are the author's. A `TODO` is prose.
12
+ */
13
+ export const DIRECTIVE_PATTERN = /^\s*(eslint-(disable|enable|env)|eslint\s+[@\w/-]+\s*:|prettier-|@ts-|globals?\s+[\w$]+(\s*:\s*\w+)?(\s*,\s*[\w$]+(\s*:\s*\w+)?)*\s*$|istanbul\s|c8\s|v8\s)/;
14
+ /** True when nothing but whitespace precedes the comment on its row. */
15
+ export const isRowStart = (sourceCode, comment) => !sourceCode.lines[comment.loc.start.line - 1]?.slice(0, comment.loc.start.column).trim();
@@ -0,0 +1,11 @@
1
+ import { type TSESTree } from "@typescript-eslint/utils";
2
+ export type FunctionNode = TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration | TSESTree.FunctionExpression;
3
+ /** A `memo(…)`/`forwardRef(…)` call, also as `React.memo(…)`. */
4
+ export declare const isWrapperCall: (node: TSESTree.Node | null | undefined) => node is TSESTree.CallExpression;
5
+ /**
6
+ * The name a function is a component under, if it is one: a capitalised
7
+ * function declaration or named function expression (`memo(function Row() {})`),
8
+ * or a capitalised const holding the function directly or through any nesting
9
+ * of `memo`/`forwardRef` (`memo(forwardRef(…))`).
10
+ */
11
+ export declare const componentName: (fn: FunctionNode) => string | undefined;