@hypersolid-dev/eslint-config 3.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 +34 -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 +95 -11
- package/dist/shared.d.ts +19 -0
- package/dist/shared.js +72 -1
- package/package.json +3 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
import ts, { TypeFlags } from "typescript";
|
|
3
|
+
import createRule from "../utils/create-rule.js";
|
|
4
|
+
import { isSameAsBangBang, undefinedComparison } from "../utils/types.js";
|
|
5
|
+
/** React draws these; every other falsy value renders as nothing. */
|
|
6
|
+
const RENDERS_WHEN_FALSY = TypeFlags.NumberLike | TypeFlags.StringLike | TypeFlags.BigIntLike;
|
|
7
|
+
/** `any` and `unknown` could be anything, so they are treated as leaking. */
|
|
8
|
+
const UNKNOWABLE = TypeFlags.Any | TypeFlags.Unknown;
|
|
9
|
+
const WHITESPACE_PATTERN = /\s+/gu;
|
|
10
|
+
/**
|
|
11
|
+
* An intersection carries its own flags, never its parts', so a branded
|
|
12
|
+
* primitive (`string & { brand }`) is walked like a union: leaking if any part
|
|
13
|
+
* leaks. A literal is exactly its value, so `"a" | "b"` and `1 | 2` never leak.
|
|
14
|
+
*/
|
|
15
|
+
const canLeak = (type) => {
|
|
16
|
+
if (type.isUnion() || type.isIntersection())
|
|
17
|
+
return type.types.some(canLeak);
|
|
18
|
+
if (type.isStringLiteral())
|
|
19
|
+
return type.value === "";
|
|
20
|
+
if (type.isNumberLiteral())
|
|
21
|
+
return type.value === 0;
|
|
22
|
+
if (type.flags & TypeFlags.BigIntLiteral) {
|
|
23
|
+
return type.value.base10Value === "0";
|
|
24
|
+
}
|
|
25
|
+
return Boolean(type.flags & (RENDERS_WHEN_FALSY | UNKNOWABLE));
|
|
26
|
+
};
|
|
27
|
+
const isRenderable = (node) => node.type === AST_NODE_TYPES.JSXElement || node.type === AST_NODE_TYPES.JSXFragment;
|
|
28
|
+
/** React skips all three, so each means "draw nothing" in the alternate slot. */
|
|
29
|
+
const isNothing = (node) => (node.type === AST_NODE_TYPES.Literal && (node.value === null || node.value === false)) ||
|
|
30
|
+
(node.type === AST_NODE_TYPES.Identifier && node.name === "undefined");
|
|
31
|
+
/**
|
|
32
|
+
* Whether this expression's value ends up drawn as a child, directly or through
|
|
33
|
+
* array literals, the right side of `&&` and the branches of a ternary.
|
|
34
|
+
*/
|
|
35
|
+
const isInRenderPosition = (node) => {
|
|
36
|
+
let current = node;
|
|
37
|
+
let { parent } = current;
|
|
38
|
+
while (parent) {
|
|
39
|
+
if (parent.type === AST_NODE_TYPES.JSXExpressionContainer) {
|
|
40
|
+
return parent.parent.type === AST_NODE_TYPES.JSXElement || parent.parent.type === AST_NODE_TYPES.JSXFragment;
|
|
41
|
+
}
|
|
42
|
+
const isPassThrough = parent.type === AST_NODE_TYPES.ArrayExpression ||
|
|
43
|
+
(parent.type === AST_NODE_TYPES.LogicalExpression && parent.right === current) ||
|
|
44
|
+
(parent.type === AST_NODE_TYPES.ConditionalExpression && (parent.consequent === current || parent.alternate === current));
|
|
45
|
+
if (!isPassThrough)
|
|
46
|
+
return false;
|
|
47
|
+
current = parent;
|
|
48
|
+
parent = parent.parent;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
/** The declarator a conditional is the whole value of, when it binds a plain name. */
|
|
53
|
+
const heldBy = (node) => node.parent?.type === AST_NODE_TYPES.VariableDeclarator && node.parent.init === node && node.parent.id.type === AST_NODE_TYPES.Identifier
|
|
54
|
+
? node.parent
|
|
55
|
+
: undefined;
|
|
56
|
+
const booleanGuard = createRule({
|
|
57
|
+
name: "boolean-guard",
|
|
58
|
+
meta: {
|
|
59
|
+
defaultOptions: [],
|
|
60
|
+
type: "problem",
|
|
61
|
+
docs: {
|
|
62
|
+
description: "Require a rendered child to be guarded with `&&`, coercing a condition that can render as 0 or an empty string.",
|
|
63
|
+
},
|
|
64
|
+
schema: [],
|
|
65
|
+
messages: {
|
|
66
|
+
preferLogical: "A rendered child guarded by a ternary is a plain `&&`. Write `{{condition}} && …` instead of `… : null`.",
|
|
67
|
+
preferLogicalCoerced: "A rendered child guarded by a ternary is a plain `&&`, and this condition can render as 0 or an empty string. Write `!!{{condition}} && …`.",
|
|
68
|
+
coerce: "`{{condition}}` can be 0 or an empty string, which React renders — outside a `<Text>` that throws. Coerce it with `!!`.",
|
|
69
|
+
preferBangBang: "`!== undefined` is `!!` for this type. Write `!!{{condition}} && …`.",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
create(context) {
|
|
73
|
+
const services = ESLintUtils.getParserServices(context, true);
|
|
74
|
+
if (!services.program)
|
|
75
|
+
return {};
|
|
76
|
+
const checker = services.program.getTypeChecker();
|
|
77
|
+
const isLeaking = (node) => canLeak(services.getTypeAtLocation(node));
|
|
78
|
+
const bangBangSubject = (test, isPresentWanted) => {
|
|
79
|
+
const compared = undefinedComparison(test);
|
|
80
|
+
if (compared?.isPresent !== isPresentWanted)
|
|
81
|
+
return undefined;
|
|
82
|
+
return isSameAsBangBang(services.getTypeAtLocation(compared.value), checker) ? compared.value : undefined;
|
|
83
|
+
};
|
|
84
|
+
/** The message is the fix, so anything that is not a isSingle term is parenthesised. */
|
|
85
|
+
const nameOf = (node) => {
|
|
86
|
+
const text = context.sourceCode.getText(node).replaceAll(WHITESPACE_PATTERN, " ");
|
|
87
|
+
const isSingle = node.type === AST_NODE_TYPES.Identifier ||
|
|
88
|
+
node.type === AST_NODE_TYPES.Literal ||
|
|
89
|
+
node.type === AST_NODE_TYPES.MemberExpression ||
|
|
90
|
+
node.type === AST_NODE_TYPES.CallExpression;
|
|
91
|
+
return isSingle ? text : `(${text})`;
|
|
92
|
+
};
|
|
93
|
+
/** Whether a name holding a guard is read, and every read is drawn. */
|
|
94
|
+
const isOnlyRendered = (declarator) => {
|
|
95
|
+
const [variable] = context.sourceCode.getDeclaredVariables(declarator);
|
|
96
|
+
const reads = variable.references.filter((reference) => reference.isRead());
|
|
97
|
+
if (reads.length === 0)
|
|
98
|
+
return false;
|
|
99
|
+
return reads.every((reference) => isInRenderPosition(reference.identifier));
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
ConditionalExpression(node) {
|
|
103
|
+
// The shape is what proves a `const` holding this is a guard.
|
|
104
|
+
const isConsequentGuarded = isRenderable(node.consequent) && isNothing(node.alternate);
|
|
105
|
+
const isAlternateGuarded = isRenderable(node.alternate) && isNothing(node.consequent);
|
|
106
|
+
if (!isConsequentGuarded && !isAlternateGuarded)
|
|
107
|
+
return;
|
|
108
|
+
if (!isInRenderPosition(node)) {
|
|
109
|
+
const declarator = heldBy(node);
|
|
110
|
+
if (!declarator || !isOnlyRendered(declarator))
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const subject = bangBangSubject(node.test, isConsequentGuarded);
|
|
114
|
+
if (subject) {
|
|
115
|
+
context.report({ node, messageId: "preferBangBang", data: { condition: nameOf(subject) } });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
context.report({
|
|
119
|
+
node,
|
|
120
|
+
// A negated test is already a boolean, so only the consequent guard can leak.
|
|
121
|
+
messageId: isConsequentGuarded && isLeaking(node.test) ? "preferLogicalCoerced" : "preferLogical",
|
|
122
|
+
data: { condition: isAlternateGuarded ? `!${nameOf(node.test)}` : nameOf(node.test) },
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
LogicalExpression(node) {
|
|
126
|
+
if (node.operator !== "&&")
|
|
127
|
+
return;
|
|
128
|
+
if (!isInRenderPosition(node))
|
|
129
|
+
return;
|
|
130
|
+
const subject = bangBangSubject(node.left, true);
|
|
131
|
+
if (subject) {
|
|
132
|
+
context.report({ node: node.left, messageId: "preferBangBang", data: { condition: nameOf(subject) } });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// The rightmost operand is the guard: `a && b && <X />` leaks only on `b`.
|
|
136
|
+
if (!isLeaking(node.left))
|
|
137
|
+
return;
|
|
138
|
+
context.report({ node: node.left, messageId: "coerce", data: { condition: nameOf(node.left) } });
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
export default booleanGuard;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
type Options = [{
|
|
3
|
+
maxLength?: number;
|
|
4
|
+
}?];
|
|
5
|
+
type MessageIds = "lineRun" | "blockRun" | "jsxRun" | "jsxBlock" | "tooLong";
|
|
6
|
+
/**
|
|
7
|
+
* Keep `//` and `/* *\/` to one row, so a caption stays a caption; a paragraph belongs in a `/** *\/` JSDoc block.
|
|
8
|
+
*
|
|
9
|
+
* Reported: a run of own-row `//` lines, or of one-row `/* *\/` blocks on
|
|
10
|
+
* consecutive own rows (a run is one comment in disguise); a `/* *\/` block,
|
|
11
|
+
* including `{/* *\/}` in JSX, spanning rows; and with `maxLength`, any row
|
|
12
|
+
* longer than it. Unset, only the row count is checked.
|
|
13
|
+
*
|
|
14
|
+
* Accepted: a JSDoc block of any number of rows; a one-row `//` or `/* *\/`; a
|
|
15
|
+
* run of nothing but directives, which each bind to the line beneath.
|
|
16
|
+
*
|
|
17
|
+
* Directives are the tool's text, so only the reason after ` -- ` is measured.
|
|
18
|
+
* A run holding any prose is still a run. A URL is exempt from the length cap.
|
|
19
|
+
*
|
|
20
|
+
* Limits: a row is judged by its text, not by whether it explains anything; a
|
|
21
|
+
* `/* *\/` block hugging code on its row is trailing and never joins a run.
|
|
22
|
+
*/
|
|
23
|
+
declare const _default: TSESLint.RuleModule<MessageIds, Options, unknown, TSESLint.RuleListener> & {
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
export default _default;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, AST_TOKEN_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
import createRule from "../utils/create-rule.js";
|
|
3
|
+
import { DIRECTIVE_PATTERN, isJsdoc, isRowStart } from "../utils/comments.js";
|
|
4
|
+
const REASON_PATTERN = /\s--\s([\s\S]*)$/;
|
|
5
|
+
const LINK_PATTERN = /\S*https?:\/\/\S*/g;
|
|
6
|
+
const JSDOC_ROW_PATTERN = /^\s*\*?\s?/;
|
|
7
|
+
/**
|
|
8
|
+
* A JSX comment is `{ / * … * / }`, a block comment alone inside an expression container.
|
|
9
|
+
*
|
|
10
|
+
* Braces either side are not enough: an empty object literal, function body or
|
|
11
|
+
* `catch` look identical from the tokens. A container holding nothing but
|
|
12
|
+
* comments parses as a JSXEmptyExpression, which exists nowhere else.
|
|
13
|
+
*/
|
|
14
|
+
const isJsxComment = (sourceCode, comment) => {
|
|
15
|
+
const before = sourceCode.getTokenBefore(comment, { includeComments: false });
|
|
16
|
+
const after = sourceCode.getTokenAfter(comment, { includeComments: false });
|
|
17
|
+
if (before?.value !== "{" || after?.value !== "}")
|
|
18
|
+
return false;
|
|
19
|
+
return sourceCode.getNodeByRangeIndex(comment.range[0])?.type === AST_NODE_TYPES.JSXEmptyExpression;
|
|
20
|
+
};
|
|
21
|
+
/** A directive's payload is the tool's; only the reason after `--` is the author's, and none means nothing to cap. */
|
|
22
|
+
const measurable = (text) => {
|
|
23
|
+
const reason = REASON_PATTERN.exec(text);
|
|
24
|
+
if (reason)
|
|
25
|
+
return reason[1];
|
|
26
|
+
return DIRECTIVE_PATTERN.test(text) ? "" : text;
|
|
27
|
+
};
|
|
28
|
+
/** A URL cannot be wrapped, so it is left out of the measured row; the rest of the row still counts. */
|
|
29
|
+
const withoutLinks = (line) => line.replaceAll(LINK_PATTERN, "");
|
|
30
|
+
const lineLength = (comment) => {
|
|
31
|
+
const lines = isJsdoc(comment)
|
|
32
|
+
? comment.value.split("\n").map((line) => line.replace(JSDOC_ROW_PATTERN, "").trimEnd())
|
|
33
|
+
: comment.value.split("\n");
|
|
34
|
+
const measured = DIRECTIVE_PATTERN.test(comment.value) ? lines.map(measurable) : lines;
|
|
35
|
+
return Math.max(0, ...measured.map((line) => withoutLinks(line).trim().length));
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Keep `//` and `/* *\/` to one row, so a caption stays a caption; a paragraph belongs in a `/** *\/` JSDoc block.
|
|
39
|
+
*
|
|
40
|
+
* Reported: a run of own-row `//` lines, or of one-row `/* *\/` blocks on
|
|
41
|
+
* consecutive own rows (a run is one comment in disguise); a `/* *\/` block,
|
|
42
|
+
* including `{/* *\/}` in JSX, spanning rows; and with `maxLength`, any row
|
|
43
|
+
* longer than it. Unset, only the row count is checked.
|
|
44
|
+
*
|
|
45
|
+
* Accepted: a JSDoc block of any number of rows; a one-row `//` or `/* *\/`; a
|
|
46
|
+
* run of nothing but directives, which each bind to the line beneath.
|
|
47
|
+
*
|
|
48
|
+
* Directives are the tool's text, so only the reason after ` -- ` is measured.
|
|
49
|
+
* A run holding any prose is still a run. A URL is exempt from the length cap.
|
|
50
|
+
*
|
|
51
|
+
* Limits: a row is judged by its text, not by whether it explains anything; a
|
|
52
|
+
* `/* *\/` block hugging code on its row is trailing and never joins a run.
|
|
53
|
+
*/
|
|
54
|
+
export default createRule({
|
|
55
|
+
name: "comment-style",
|
|
56
|
+
meta: {
|
|
57
|
+
defaultOptions: [{}],
|
|
58
|
+
type: "layout",
|
|
59
|
+
docs: {
|
|
60
|
+
description: "Keep `//` and `/* */` to one row; a longer explanation is a `/** */` JSDoc block.",
|
|
61
|
+
},
|
|
62
|
+
schema: [
|
|
63
|
+
{
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { maxLength: { type: "integer", minimum: 1 } },
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
messages: {
|
|
70
|
+
lineRun: "This comment runs to {{rows}} rows. Say it in one `//` or `/* */` line, or move the explanation to a `/** */` JSDoc block.",
|
|
71
|
+
blockRun: "This comment runs to {{rows}} rows. Say it in one `//` or `/* */` line, or move the explanation to a `/** */` JSDoc block.",
|
|
72
|
+
jsxRun: "These JSX comments run to {{rows}} rows. Say it in one `{/* */}` line, or move the explanation to a JSDoc block above the component.",
|
|
73
|
+
jsxBlock: "This JSX comment runs to {{rows}} rows. Say it in one line, or move the explanation to a JSDoc block above the component.",
|
|
74
|
+
tooLong: "This comment line is {{length}} characters. Keep it to {{max}}.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
create(context) {
|
|
78
|
+
const { sourceCode } = context;
|
|
79
|
+
const maxLength = context.options[0]?.maxLength;
|
|
80
|
+
const isTooLong = (comment) => Boolean(maxLength) && lineLength(comment) > (maxLength ?? 0);
|
|
81
|
+
const reportTooLong = (comment) => {
|
|
82
|
+
context.report({
|
|
83
|
+
loc: comment.loc,
|
|
84
|
+
messageId: "tooLong",
|
|
85
|
+
data: { length: String(lineLength(comment)), max: String(maxLength) },
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
"Program:exit"() {
|
|
90
|
+
let run = [];
|
|
91
|
+
const doesContinue = (comment) => {
|
|
92
|
+
const last = run.at(-1);
|
|
93
|
+
return Boolean(last) && comment.loc.start.line === (last?.loc.end.line ?? 0) + 1;
|
|
94
|
+
};
|
|
95
|
+
const flush = () => {
|
|
96
|
+
const [first] = run;
|
|
97
|
+
const last = run.at(-1);
|
|
98
|
+
if (last && run.length > 1 && run.some((entry) => !DIRECTIVE_PATTERN.test(entry.value))) {
|
|
99
|
+
context.report({
|
|
100
|
+
loc: { start: first.loc.start, end: last.loc.end },
|
|
101
|
+
// `//` cannot be written inside JSX, so advising it there would be unfollowable.
|
|
102
|
+
messageId: run.some((entry) => isJsxComment(sourceCode, entry)) ? "jsxRun" : "lineRun",
|
|
103
|
+
data: { rows: String(run.length) },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
else if (run.length === 1 && isTooLong(first)) {
|
|
107
|
+
reportTooLong(first);
|
|
108
|
+
}
|
|
109
|
+
run = [];
|
|
110
|
+
};
|
|
111
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
112
|
+
if (comment.type === AST_TOKEN_TYPES.Block) {
|
|
113
|
+
const rows = comment.loc.end.line - comment.loc.start.line + 1;
|
|
114
|
+
// A JSX comment's `{` is syntax, so a row holding only `{/* … */}` starts its row.
|
|
115
|
+
const isOwnRow = isRowStart(sourceCode, comment) ||
|
|
116
|
+
(isJsxComment(sourceCode, comment) &&
|
|
117
|
+
!sourceCode.lines[comment.loc.start.line - 1]
|
|
118
|
+
?.slice(0, Math.max(0, comment.loc.start.column - 1))
|
|
119
|
+
.trim());
|
|
120
|
+
if (!isJsdoc(comment) && rows === 1 && isOwnRow) {
|
|
121
|
+
if (!doesContinue(comment))
|
|
122
|
+
flush();
|
|
123
|
+
run.push(comment);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
flush();
|
|
127
|
+
if (isJsdoc(comment)) {
|
|
128
|
+
if (isTooLong(comment))
|
|
129
|
+
reportTooLong(comment);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (rows > 1) {
|
|
133
|
+
context.report({
|
|
134
|
+
loc: comment.loc,
|
|
135
|
+
messageId: isJsxComment(sourceCode, comment) ? "jsxBlock" : "blockRun",
|
|
136
|
+
data: { rows: String(rows) },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else if (isTooLong(comment)) {
|
|
140
|
+
reportTooLong(comment);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!doesContinue(comment))
|
|
145
|
+
flush();
|
|
146
|
+
if (!isRowStart(sourceCode, comment)) {
|
|
147
|
+
flush();
|
|
148
|
+
if (isTooLong(comment))
|
|
149
|
+
reportTooLong(comment);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
run.push(comment);
|
|
153
|
+
}
|
|
154
|
+
flush();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hook named `use<Something>Effect` takes the callback it runs as its first
|
|
3
|
+
* parameter, like `useEffect`, `useLayoutEffect` and `useFocusEffect`.
|
|
4
|
+
*
|
|
5
|
+
* Reports a function declaration, or a `const` bound to an arrow or function
|
|
6
|
+
* expression, named `use` + a capitalised word + `Effect` whose first parameter
|
|
7
|
+
* is missing or is not a function. A parameter counts as a function when it is
|
|
8
|
+
* annotated with a function type, `EffectCallback` or a union of those with
|
|
9
|
+
* `undefined`, has a function default, or, with type information, when its
|
|
10
|
+
* type has a call signature (through an alias or a generic bound). `any` is
|
|
11
|
+
* given the benefit of the doubt.
|
|
12
|
+
*
|
|
13
|
+
* Limits: without type information only the annotation is read. A hook ending
|
|
14
|
+
* in `Effect` for another reason (`useSoundEffect(name)`) is reported; rename
|
|
15
|
+
* it or disable the rule on that line. A hook assigned from a call is not seen.
|
|
16
|
+
*/
|
|
17
|
+
declare const effectTakesCallback: import("@typescript-eslint/utils/ts-eslint").RuleModule<"callbackFirst", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
export default effectTakesCallback;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
import ts, { TypeFlags } from "typescript";
|
|
3
|
+
import createRule from "../utils/create-rule.js";
|
|
4
|
+
const EFFECT_HOOK_PATTERN = /^use[A-Z]\w*Effect$/;
|
|
5
|
+
const NOTHING = TypeFlags.Undefined | TypeFlags.Null | TypeFlags.Void;
|
|
6
|
+
const ABSENT = new Set(["TSUndefinedKeyword", "TSNullKeyword", "TSVoidKeyword"]);
|
|
7
|
+
/** A function type, `EffectCallback`, or a union of those with `undefined`/`null`. */
|
|
8
|
+
const isCallbackAnnotation = (type) => {
|
|
9
|
+
if (type?.type === AST_NODE_TYPES.TSUnionType) {
|
|
10
|
+
const rest = type.types.filter((part) => !ABSENT.has(part.type));
|
|
11
|
+
return rest.length > 0 && rest.every(isCallbackAnnotation);
|
|
12
|
+
}
|
|
13
|
+
if (type?.type === AST_NODE_TYPES.TSFunctionType)
|
|
14
|
+
return true;
|
|
15
|
+
if (type?.type !== AST_NODE_TYPES.TSTypeReference)
|
|
16
|
+
return false;
|
|
17
|
+
const { typeName } = type;
|
|
18
|
+
if (typeName.type === AST_NODE_TYPES.Identifier)
|
|
19
|
+
return typeName.name === "EffectCallback";
|
|
20
|
+
return typeName.type === AST_NODE_TYPES.TSQualifiedName && typeName.right.name === "EffectCallback";
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A hook named `use<Something>Effect` takes the callback it runs as its first
|
|
24
|
+
* parameter, like `useEffect`, `useLayoutEffect` and `useFocusEffect`.
|
|
25
|
+
*
|
|
26
|
+
* Reports a function declaration, or a `const` bound to an arrow or function
|
|
27
|
+
* expression, named `use` + a capitalised word + `Effect` whose first parameter
|
|
28
|
+
* is missing or is not a function. A parameter counts as a function when it is
|
|
29
|
+
* annotated with a function type, `EffectCallback` or a union of those with
|
|
30
|
+
* `undefined`, has a function default, or, with type information, when its
|
|
31
|
+
* type has a call signature (through an alias or a generic bound). `any` is
|
|
32
|
+
* given the benefit of the doubt.
|
|
33
|
+
*
|
|
34
|
+
* Limits: without type information only the annotation is read. A hook ending
|
|
35
|
+
* in `Effect` for another reason (`useSoundEffect(name)`) is reported; rename
|
|
36
|
+
* it or disable the rule on that line. A hook assigned from a call is not seen.
|
|
37
|
+
*/
|
|
38
|
+
const effectTakesCallback = createRule({
|
|
39
|
+
name: "effect-takes-callback",
|
|
40
|
+
meta: {
|
|
41
|
+
defaultOptions: [],
|
|
42
|
+
type: "suggestion",
|
|
43
|
+
docs: {
|
|
44
|
+
description: "A use*Effect hook takes the callback it runs as its first parameter, like useEffect and useFocusEffect.",
|
|
45
|
+
},
|
|
46
|
+
schema: [],
|
|
47
|
+
messages: {
|
|
48
|
+
callbackFirst: "`{{name}}` ends in Effect but its first parameter is not a callback. Take the callback first, or drop the Effect suffix.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
create(context) {
|
|
52
|
+
const services = context.sourceCode.parserServices;
|
|
53
|
+
const checker = services?.program ? services.program.getTypeChecker() : undefined;
|
|
54
|
+
/** Whether a value of this type can be called, once `undefined` is set aside. */
|
|
55
|
+
const isCallable = (type) => {
|
|
56
|
+
if (type.flags & TypeFlags.Any)
|
|
57
|
+
return true;
|
|
58
|
+
if (type.isUnion()) {
|
|
59
|
+
const rest = type.types.filter((part) => !(part.flags & NOTHING));
|
|
60
|
+
return rest.length > 0 && rest.every(isCallable);
|
|
61
|
+
}
|
|
62
|
+
if (type.getCallSignatures().length > 0)
|
|
63
|
+
return true;
|
|
64
|
+
if (checker && type.flags & TypeFlags.TypeParameter) {
|
|
65
|
+
const constraint = checker.getBaseConstraintOfType(type);
|
|
66
|
+
return Boolean(constraint) && isCallable(constraint);
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
};
|
|
70
|
+
const isCallback = (parameter) => {
|
|
71
|
+
if (!parameter)
|
|
72
|
+
return false;
|
|
73
|
+
if (parameter.type === AST_NODE_TYPES.AssignmentPattern && (parameter.right.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
74
|
+
parameter.right.type === AST_NODE_TYPES.FunctionExpression)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const node = parameter.type === AST_NODE_TYPES.AssignmentPattern ? parameter.left : parameter;
|
|
78
|
+
const annotation = "typeAnnotation" in node ? node.typeAnnotation : undefined;
|
|
79
|
+
if (isCallbackAnnotation(annotation?.typeAnnotation))
|
|
80
|
+
return true;
|
|
81
|
+
if (!checker || !services?.esTreeNodeToTSNodeMap)
|
|
82
|
+
return false;
|
|
83
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
|
84
|
+
return isCallable(checker.getTypeAtLocation(tsNode));
|
|
85
|
+
};
|
|
86
|
+
const check = (id, fn) => {
|
|
87
|
+
if (id?.type !== AST_NODE_TYPES.Identifier || !EFFECT_HOOK_PATTERN.test(id.name))
|
|
88
|
+
return;
|
|
89
|
+
if (!isCallback(fn.params[0])) {
|
|
90
|
+
context.report({ node: id, messageId: "callbackFirst", data: { name: id.name } });
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
FunctionDeclaration: (node) => {
|
|
95
|
+
check(node.id, node);
|
|
96
|
+
},
|
|
97
|
+
VariableDeclarator: (node) => {
|
|
98
|
+
if (node.init?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
99
|
+
node.init?.type === AST_NODE_TYPES.FunctionExpression) {
|
|
100
|
+
check(node.id, node.init);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
export default effectTakesCallback;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A comment on a type member is a JSDoc block, the only form an editor shows when the member is hovered elsewhere.
|
|
3
|
+
*
|
|
4
|
+
* Reported: a `//` line comment or a plain `/* *\/` block directly above a
|
|
5
|
+
* property or method signature of a type literal or an interface.
|
|
6
|
+
*
|
|
7
|
+
* Accepted: a JSDoc block of any number of rows; no comment; a comment trailing
|
|
8
|
+
* code on its row; a comment separated from the member by a blank line. A
|
|
9
|
+
* directive is skipped: it binds to the line beneath it, so it neither
|
|
10
|
+
* documents the member nor hides the comment above itself.
|
|
11
|
+
*
|
|
12
|
+
* Limits: only the nearest comment above the member is judged. Class members,
|
|
13
|
+
* object-literal properties, enum members and call signatures are not checked.
|
|
14
|
+
*/
|
|
15
|
+
declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"form", never[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
export default _default;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AST_TOKEN_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
import createRule from "../utils/create-rule.js";
|
|
3
|
+
import { DIRECTIVE_PATTERN, isJsdoc, isRowStart } from "../utils/comments.js";
|
|
4
|
+
/**
|
|
5
|
+
* A comment on a type member is a JSDoc block, the only form an editor shows when the member is hovered elsewhere.
|
|
6
|
+
*
|
|
7
|
+
* Reported: a `//` line comment or a plain `/* *\/` block directly above a
|
|
8
|
+
* property or method signature of a type literal or an interface.
|
|
9
|
+
*
|
|
10
|
+
* Accepted: a JSDoc block of any number of rows; no comment; a comment trailing
|
|
11
|
+
* code on its row; a comment separated from the member by a blank line. A
|
|
12
|
+
* directive is skipped: it binds to the line beneath it, so it neither
|
|
13
|
+
* documents the member nor hides the comment above itself.
|
|
14
|
+
*
|
|
15
|
+
* Limits: only the nearest comment above the member is judged. Class members,
|
|
16
|
+
* object-literal properties, enum members and call signatures are not checked.
|
|
17
|
+
*/
|
|
18
|
+
export default createRule({
|
|
19
|
+
name: "jsdoc-for-props",
|
|
20
|
+
meta: {
|
|
21
|
+
defaultOptions: [],
|
|
22
|
+
type: "suggestion",
|
|
23
|
+
docs: {
|
|
24
|
+
description: "A comment above a type member is a JSDoc block, so an editor shows it at the call site.",
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: {
|
|
28
|
+
form: "Write this as a JSDoc block, not `{{ form }}`. A member's comment is what an editor shows when someone hovers it elsewhere, and only JSDoc reaches there.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
const { sourceCode } = context;
|
|
33
|
+
const check = (node) => {
|
|
34
|
+
// A directive neither documents the member nor separates it from the comment above.
|
|
35
|
+
let floor = node.loc.start.line;
|
|
36
|
+
let comment;
|
|
37
|
+
for (const entry of sourceCode.getCommentsBefore(node).toReversed()) {
|
|
38
|
+
if (!isRowStart(sourceCode, entry))
|
|
39
|
+
continue;
|
|
40
|
+
if (DIRECTIVE_PATTERN.test(entry.value)) {
|
|
41
|
+
floor = entry.loc.start.line;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
comment = entry;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (!comment || comment.loc.end.line !== floor - 1 || isJsdoc(comment))
|
|
48
|
+
return;
|
|
49
|
+
context.report({
|
|
50
|
+
node: comment,
|
|
51
|
+
messageId: "form",
|
|
52
|
+
data: { form: comment.type === AST_TOKEN_TYPES.Line ? "a line comment" : "a plain block" },
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
return { TSPropertySignature: check, TSMethodSignature: check };
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
/**
|
|
3
|
+
* A component forwards an event; the caller builds the payload.
|
|
4
|
+
*
|
|
5
|
+
* Reports, in an exported component (a function whose name starts with a
|
|
6
|
+
* capital, also inside `memo`/`forwardRef`), a call of one of its own `on*`
|
|
7
|
+
* props when every argument is built only from its props and literals and at
|
|
8
|
+
* least one prop is handed straight back (`onPress(teamId)`,
|
|
9
|
+
* `onPlay({ mediaId })`). Left alone: a value the component produced itself
|
|
10
|
+
* (an index it tracks, input text, `!isOn`), a callback called with different
|
|
11
|
+
* arguments in different places (the argument says which control was
|
|
12
|
+
* pressed), and components the file does not export.
|
|
13
|
+
*
|
|
14
|
+
* Props are the first parameter's bindings, resolved through scope, so a local
|
|
15
|
+
* that shadows a prop's name is the component's own value. Limits: a value
|
|
16
|
+
* derived into a local first (`const id = item.id`) is not followed.
|
|
17
|
+
*/
|
|
18
|
+
declare const noCallbackPayload: TSESLint.RuleModule<"payload", [], unknown, TSESLint.RuleListener> & {
|
|
19
|
+
name: string;
|
|
20
|
+
};
|
|
21
|
+
export default noCallbackPayload;
|