eslint-plugin-no-mistakes 0.76.0 → 0.77.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/README.md +25 -1
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/src/rule-suppression.js +32 -0
- package/src/rules/postgres-executor.js +2 -2
- package/src/rules/postgres-no-manual-transaction.js +1 -1
- package/src/rules/postgres-no-unbounded-query-fanout.js +1 -1
- package/src/rules/postgres-scoped-executors.js +96 -4
- package/src/rules/test-no-skips-bindings.js +71 -0
- package/src/rules/test-no-skips.js +45 -0
- package/src/rules/vitest-timeout-bindings.js +55 -0
- package/src/rules/vitest-timeout-cap.js +126 -0
- package/src/rules/vitest-timeout-values.js +148 -0
package/README.md
CHANGED
|
@@ -9,4 +9,28 @@ callers, fetches, or selectors behind aliases and dynamic values. See
|
|
|
9
9
|
npm install --save-dev eslint-plugin-no-mistakes
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Configure the plugin in a flat config and scope rules with `files` globs. For
|
|
13
|
+
example, this checks test files for skipped or exclusive tests while allowing
|
|
14
|
+
`todo`:
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
import noMistakes from "eslint-plugin-no-mistakes";
|
|
18
|
+
|
|
19
|
+
export default [
|
|
20
|
+
{
|
|
21
|
+
files: ["**/*.test.*", "tests/**"],
|
|
22
|
+
plugins: { "no-mistakes": noMistakes },
|
|
23
|
+
rules: { "no-mistakes/test-no-skips": ["error", { allow: ["todo"] }] },
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
See [`test-no-skips`](../../docs/eslint-rules/test-no-skips.md) for the rule's
|
|
29
|
+
behavior and configuration, and the [ESLint rule index](../../docs/eslint-rules/README.md)
|
|
30
|
+
for the complete list.
|
|
31
|
+
|
|
32
|
+
To cap long Vitest timeouts, enable `no-mistakes/vitest-timeout-cap` in the
|
|
33
|
+
test and Vitest config files selected by your flat-config globs. It defaults to
|
|
34
|
+
5,000 ms for configured test and hook timeouts and 30,000 ms for per-test and
|
|
35
|
+
hook overrides. See the [rule documentation](../../docs/eslint-rules/vitest-timeout-cap.md)
|
|
36
|
+
for options and examples.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -40,11 +40,13 @@ const rules = {
|
|
|
40
40
|
"server-require-nullable-fetch-wrapper": require("./rules/server-require-nullable-fetch-wrapper"),
|
|
41
41
|
"test-no-error-message-matching": require("./rules/test-no-error-message-matching"),
|
|
42
42
|
"test-no-delayed-rejects": require("./rules/test-no-delayed-rejects"),
|
|
43
|
+
"test-no-skips": require("./rules/test-no-skips"),
|
|
43
44
|
"test-no-shared-state": require("./rules/test-no-shared-state"),
|
|
44
45
|
"ts-no-const-aliases": require("./rules/ts-no-const-aliases"),
|
|
45
46
|
"ts-no-export-renaming": require("./rules/ts-no-export-renaming"),
|
|
46
47
|
"ts-no-function-aliases": require("./rules/ts-no-function-aliases"),
|
|
47
48
|
"ts-preserve-null-option-defaults": require("./rules/ts-preserve-null-option-defaults"),
|
|
49
|
+
"vitest-timeout-cap": require("./rules/vitest-timeout-cap"),
|
|
48
50
|
"vitest-mock-test-file-naming": require("./rules/vitest-mock-test-file-naming"),
|
|
49
51
|
};
|
|
50
52
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Parse actual comments once; directive-looking string literals never suppress.
|
|
4
|
+
function ruleSuppression(context, ruleId) {
|
|
5
|
+
const lines = new Set();
|
|
6
|
+
let file = false;
|
|
7
|
+
const source = context.sourceCode;
|
|
8
|
+
const first = source.ast.tokens[0];
|
|
9
|
+
for (const comment of source.getAllComments()) {
|
|
10
|
+
for (const [offset, text] of comment.value.split(/\r?\n/).entries()) {
|
|
11
|
+
const match = /^\s*\*?\s*no-mistakes-disable-(file|line|next-line)\b(.*)$/.exec(text);
|
|
12
|
+
if (!match) continue;
|
|
13
|
+
const names = match[2]
|
|
14
|
+
.split("--")[0]
|
|
15
|
+
.trim()
|
|
16
|
+
.split(/[\s,]+/)
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
if (
|
|
19
|
+
names.length &&
|
|
20
|
+
!names.some((name) => name === ruleId || name === `no-mistakes/${ruleId}`)
|
|
21
|
+
)
|
|
22
|
+
continue;
|
|
23
|
+
if (match[1] === "file") {
|
|
24
|
+
if (!first || comment.range[1] <= first.range[0]) file = true;
|
|
25
|
+
} else {
|
|
26
|
+
lines.add(comment.loc.start.line + offset + (match[1] === "next-line" ? 1 : 0));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return (node) => file || lines.has(node.loc.start.line);
|
|
31
|
+
}
|
|
32
|
+
module.exports = { ruleSuppression };
|
|
@@ -59,11 +59,11 @@ function importedName(specifier) {
|
|
|
59
59
|
return imported.type === "Literal" ? String(imported.value) : imported.name;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function executorBindings(program, options = {}) {
|
|
62
|
+
function executorBindings(program, options = {}, filename) {
|
|
63
63
|
const bindings = new Set();
|
|
64
64
|
const defaults = executorOptionDefaults(options);
|
|
65
65
|
const { importSpecifier, executorNames } = defaults;
|
|
66
|
-
bindings.scoped = collectScopedExecutors(program, defaults);
|
|
66
|
+
bindings.scoped = collectScopedExecutors(program, defaults, filename);
|
|
67
67
|
bindings.queryMembers = Boolean(importSpecifier) || executorNames.includes(QUERY_PROPERTY);
|
|
68
68
|
for (const statement of program?.body ?? []) {
|
|
69
69
|
if (statement.type !== "ImportDeclaration") continue;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
3
5
|
const { childNodes, unwrapTs } = require("./postgres-query-text");
|
|
4
6
|
|
|
7
|
+
const RELATIVE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".jsx"];
|
|
8
|
+
|
|
5
9
|
const FUNCTION_TYPES = new Set([
|
|
6
10
|
"FunctionDeclaration",
|
|
7
11
|
"FunctionExpression",
|
|
@@ -25,14 +29,102 @@ function fromConfiguredModule(source, specifier) {
|
|
|
25
29
|
return !specifier || source === specifier || String(source).startsWith(`${specifier}/`);
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
function packageName(specifier) {
|
|
33
|
+
if (
|
|
34
|
+
typeof specifier !== "string" ||
|
|
35
|
+
specifier === "" ||
|
|
36
|
+
specifier.startsWith(".") ||
|
|
37
|
+
specifier.startsWith("/")
|
|
38
|
+
) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (specifier.startsWith("@")) {
|
|
42
|
+
const slash = specifier.indexOf("/");
|
|
43
|
+
if (slash <= 1) return null;
|
|
44
|
+
const rest = specifier.slice(slash + 1);
|
|
45
|
+
const name = rest.split("/")[0];
|
|
46
|
+
if (!name) return null;
|
|
47
|
+
return specifier.slice(0, slash + 1 + name.length);
|
|
48
|
+
}
|
|
49
|
+
const name = specifier.split("/")[0];
|
|
50
|
+
return name || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRelativeSpecifier(source) {
|
|
54
|
+
return (
|
|
55
|
+
source === "." ||
|
|
56
|
+
source === ".." ||
|
|
57
|
+
(typeof source === "string" && (source.startsWith("./") || source.startsWith("../")))
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readPackageName(dir) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
64
|
+
return typeof parsed.name === "string" ? parsed.name : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Walk up from the linted file. A missing or unreadable root fails closed.
|
|
71
|
+
function packageRoot(filename, specifier) {
|
|
72
|
+
const name = packageName(specifier);
|
|
73
|
+
if (!name || typeof filename !== "string" || filename === "") return null;
|
|
74
|
+
let dir = path.dirname(path.resolve(filename));
|
|
75
|
+
const stop = path.parse(dir).root;
|
|
76
|
+
while (dir) {
|
|
77
|
+
if (readPackageName(dir) === name) return dir;
|
|
78
|
+
const nested = path.join(dir, "node_modules", ...name.split("/"));
|
|
79
|
+
if (readPackageName(nested) === name) return nested;
|
|
80
|
+
if (dir === stop) return null;
|
|
81
|
+
const parent = path.dirname(dir);
|
|
82
|
+
if (parent === dir) return null;
|
|
83
|
+
dir = parent;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveRelativeFile(filename, specifier) {
|
|
89
|
+
if (typeof filename !== "string" || typeof specifier !== "string") return null;
|
|
90
|
+
const base = path.resolve(path.dirname(path.resolve(filename)), specifier);
|
|
91
|
+
const candidates = [base];
|
|
92
|
+
for (const extension of RELATIVE_EXTENSIONS) candidates.push(`${base}${extension}`);
|
|
93
|
+
for (const extension of RELATIVE_EXTENSIONS) {
|
|
94
|
+
candidates.push(path.join(base, `index${extension}`));
|
|
95
|
+
}
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
try {
|
|
98
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
99
|
+
} catch {
|
|
100
|
+
// Missing paths and unreadable entries fail closed.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fileInsidePackage(file, root) {
|
|
107
|
+
const relative = path.relative(root, file);
|
|
108
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchesConfiguredModule(source, specifier, filename) {
|
|
112
|
+
if (fromConfiguredModule(source, specifier)) return true;
|
|
113
|
+
if (!specifier || !isRelativeSpecifier(source)) return false;
|
|
114
|
+
const root = packageRoot(filename, specifier);
|
|
115
|
+
if (!root) return false;
|
|
116
|
+
const resolved = resolveRelativeFile(filename, source);
|
|
117
|
+
return resolved !== null && fileInsidePackage(resolved, root);
|
|
118
|
+
}
|
|
119
|
+
|
|
28
120
|
// Local names of configured factory (value) and type imports, by module.
|
|
29
|
-
function scopedImports(program, options) {
|
|
121
|
+
function scopedImports(program, options, filename) {
|
|
30
122
|
const factories = new Set();
|
|
31
123
|
const types = new Set();
|
|
32
124
|
const { importSpecifier, executorFactoryNames, executorTypeNames } = options;
|
|
33
125
|
for (const statement of program?.body ?? []) {
|
|
34
126
|
if (statement.type !== "ImportDeclaration") continue;
|
|
35
|
-
if (!
|
|
127
|
+
if (!matchesConfiguredModule(statement.source?.value, importSpecifier, filename)) continue;
|
|
36
128
|
for (const specifier of statement.specifiers ?? []) {
|
|
37
129
|
if (specifier.type !== "ImportSpecifier") continue;
|
|
38
130
|
const imported = importedName(specifier);
|
|
@@ -94,9 +186,9 @@ function parameterNames(param, types) {
|
|
|
94
186
|
* configured factory call (the declaring block) and parameters typed with a
|
|
95
187
|
* configured type (the declaring function). Returns `{ name, range }` entries.
|
|
96
188
|
*/
|
|
97
|
-
function collectScopedExecutors(program, options) {
|
|
189
|
+
function collectScopedExecutors(program, options, filename) {
|
|
98
190
|
const found = [];
|
|
99
|
-
const { factories, types } = scopedImports(program, options);
|
|
191
|
+
const { factories, types } = scopedImports(program, options, filename);
|
|
100
192
|
if (factories.size === 0 && types.size === 0) return found;
|
|
101
193
|
const walk = (node, scope) => {
|
|
102
194
|
if (SCOPE_TYPES.has(node.type)) scope = node;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { resolveVariable } = require("./test-no-shared-state-aliases");
|
|
3
|
+
const { importSpecifierName } = require("./test-no-shared-state-callees");
|
|
4
|
+
const { unwrapExpression, isFunction } = require("./async-ast");
|
|
5
|
+
const NAMES = new Set(["it", "test", "describe"]);
|
|
6
|
+
const MODULES = new Set(["vitest", "@playwright/test"]);
|
|
7
|
+
const CHAIN = new Set(
|
|
8
|
+
"skip skipIf runIf only todo fixme each for concurrent sequential fails fail serial parallel describe".split(
|
|
9
|
+
" ",
|
|
10
|
+
),
|
|
11
|
+
);
|
|
12
|
+
function memberName(node) {
|
|
13
|
+
return node.computed
|
|
14
|
+
? node.property.type === "Literal" && typeof node.property.value === "string"
|
|
15
|
+
? node.property.value
|
|
16
|
+
: null
|
|
17
|
+
: node.property.name;
|
|
18
|
+
}
|
|
19
|
+
function imported(node, context, namespace = false) {
|
|
20
|
+
const variable = resolveVariable(node, context);
|
|
21
|
+
for (const def of variable?.defs || []) {
|
|
22
|
+
if (def.type !== "ImportBinding" || !MODULES.has(def.parent.source.value)) continue;
|
|
23
|
+
if (namespace) {
|
|
24
|
+
if (def.node.type === "ImportNamespaceSpecifier") return def.parent.source.value;
|
|
25
|
+
} else {
|
|
26
|
+
const name = importSpecifierName(def.node);
|
|
27
|
+
if (NAMES.has(name)) return { framework: def.parent.source.value, name };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Unbound runner globals are considered only in files selected by lint config.
|
|
31
|
+
if (!namespace && !variable?.defs.length && NAMES.has(node.name))
|
|
32
|
+
return { framework: "vitest", name: node.name };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function testBinding(node, context) {
|
|
36
|
+
node = unwrapExpression(node);
|
|
37
|
+
if (node.type === "Identifier") return imported(node, context);
|
|
38
|
+
if (node.type === "CallExpression") return testBinding(node.callee, context);
|
|
39
|
+
if (node.type === "TaggedTemplateExpression") return testBinding(node.tag, context);
|
|
40
|
+
if (node.type !== "MemberExpression") return null;
|
|
41
|
+
const name = memberName(node);
|
|
42
|
+
if (NAMES.has(name) && node.object.type === "Identifier") {
|
|
43
|
+
const framework = imported(node.object, context, true);
|
|
44
|
+
if (framework) return { framework, name };
|
|
45
|
+
}
|
|
46
|
+
if (!CHAIN.has(name)) return null;
|
|
47
|
+
const binding = testBinding(node.object, context);
|
|
48
|
+
if (!binding) return null;
|
|
49
|
+
if (name === "each" || name === "for") return { ...binding, table: name };
|
|
50
|
+
return name === "describe" ? { ...binding, name } : binding;
|
|
51
|
+
}
|
|
52
|
+
function isContextSkip(node, context) {
|
|
53
|
+
const object = unwrapExpression(node.object);
|
|
54
|
+
if (object.type !== "Identifier") return false;
|
|
55
|
+
const variable = resolveVariable(object, context);
|
|
56
|
+
for (const def of variable?.defs || []) {
|
|
57
|
+
const callback = def.node;
|
|
58
|
+
if (def.type !== "Parameter" || !isFunction(callback)) continue;
|
|
59
|
+
const call = callback.parent;
|
|
60
|
+
if (call.type !== "CallExpression" || !call.arguments.includes(callback)) continue;
|
|
61
|
+
const binding = testBinding(call.callee, context);
|
|
62
|
+
if (binding?.framework !== "vitest" || binding.name === "describe" || binding.table === "each")
|
|
63
|
+
continue;
|
|
64
|
+
// each supplies table values; for preserves its second parameter as TestContext.
|
|
65
|
+
const parameter = callback.params[binding.table === "for" ? 1 : 0];
|
|
66
|
+
if (parameter?.type === "Identifier" && resolveVariable(parameter, context) === variable)
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
module.exports = { memberName, testBinding, isContextSkip };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { rule, options } = require("../helpers");
|
|
3
|
+
const { memberName, testBinding, isContextSkip } = require("./test-no-skips-bindings");
|
|
4
|
+
const { ruleSuppression } = require("../rule-suppression");
|
|
5
|
+
const MODIFIERS = ["skip", "skipIf", "runIf", "only", "todo", "fixme"];
|
|
6
|
+
module.exports = rule(
|
|
7
|
+
{
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description: "reject skipped, conditional, pending, and focused tests",
|
|
11
|
+
recommended: false,
|
|
12
|
+
},
|
|
13
|
+
schema: [
|
|
14
|
+
{
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: { allow: { type: "array", items: { enum: MODIFIERS }, uniqueItems: true } },
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
messages: {
|
|
21
|
+
modifier:
|
|
22
|
+
"Test modifier '{{modifier}}' can remove CI coverage. Run the complete test and provide its required environment, or explicitly allow this modifier.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
(context) => {
|
|
26
|
+
const suppressed = ruleSuppression(context, "test-no-skips");
|
|
27
|
+
const banned = new Set(
|
|
28
|
+
MODIFIERS.filter((name) => !(options(context).allow || []).includes(name)),
|
|
29
|
+
);
|
|
30
|
+
return {
|
|
31
|
+
MemberExpression(node) {
|
|
32
|
+
const modifier = memberName(node);
|
|
33
|
+
if (!banned.has(modifier) || suppressed(node.property)) return;
|
|
34
|
+
const test = testBinding(node.object, context);
|
|
35
|
+
const contextCall =
|
|
36
|
+
modifier === "skip" &&
|
|
37
|
+
node.parent.type === "CallExpression" &&
|
|
38
|
+
node.parent.callee === node &&
|
|
39
|
+
isContextSkip(node, context);
|
|
40
|
+
if (!test && !contextCall) return;
|
|
41
|
+
context.report({ node: node.property, messageId: "modifier", data: { modifier } });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { resolveVariable } = require("./test-no-shared-state-aliases");
|
|
3
|
+
const { importSpecifierName } = require("./test-no-shared-state-callees");
|
|
4
|
+
const { memberName, testBinding } = require("./test-no-skips-bindings");
|
|
5
|
+
const { unwrapExpression } = require("./async-ast");
|
|
6
|
+
const HOOKS = new Set(["beforeAll", "beforeEach", "afterAll", "afterEach"]);
|
|
7
|
+
const CONFIG = new Set(["defineConfig", "defineProject", "mergeConfig"]);
|
|
8
|
+
function bindingName(node, context, modules, names) {
|
|
9
|
+
node = unwrapExpression(node);
|
|
10
|
+
if (node.type === "MemberExpression" && node.object.type === "Identifier") {
|
|
11
|
+
const variable = resolveVariable(node.object, context);
|
|
12
|
+
const name = memberName(node);
|
|
13
|
+
return names.has(name) &&
|
|
14
|
+
variable?.defs.some(
|
|
15
|
+
(def) =>
|
|
16
|
+
def.type === "ImportBinding" &&
|
|
17
|
+
modules.has(def.parent.source.value) &&
|
|
18
|
+
def.node.type === "ImportNamespaceSpecifier",
|
|
19
|
+
)
|
|
20
|
+
? name
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
if (node.type !== "Identifier") return null;
|
|
24
|
+
const variable = resolveVariable(node, context);
|
|
25
|
+
for (const def of variable?.defs || []) {
|
|
26
|
+
if (def.type === "ImportBinding" && modules.has(def.parent.source.value)) {
|
|
27
|
+
const name = importSpecifierName(def.node);
|
|
28
|
+
if (names.has(name)) return name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (modules.has("vitest") && !variable?.defs.length && names.has(node.name)) return node.name;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function configCall(node, context) {
|
|
35
|
+
return bindingName(node.callee, context, new Set(["vitest/config", "vite"]), CONFIG);
|
|
36
|
+
}
|
|
37
|
+
function overrideCall(node, context) {
|
|
38
|
+
const callee = unwrapExpression(node.callee);
|
|
39
|
+
if (
|
|
40
|
+
callee.type === "MemberExpression" &&
|
|
41
|
+
memberName(callee) === "setConfig" &&
|
|
42
|
+
bindingName(callee.object, context, new Set(["vitest"]), new Set(["vi"]))
|
|
43
|
+
)
|
|
44
|
+
return "runtime";
|
|
45
|
+
if (bindingName(callee, context, new Set(["vitest"]), HOOKS)) return "hook";
|
|
46
|
+
const test = testBinding(callee, context);
|
|
47
|
+
if (test?.framework !== "vitest") return null;
|
|
48
|
+
if (
|
|
49
|
+
callee.type === "MemberExpression" &&
|
|
50
|
+
new Set(["each", "for", "skipIf", "runIf"]).has(memberName(callee))
|
|
51
|
+
)
|
|
52
|
+
return null;
|
|
53
|
+
return "test";
|
|
54
|
+
}
|
|
55
|
+
module.exports = { configCall, overrideCall };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { rule, options } = require("../helpers");
|
|
3
|
+
const { isFunction } = require("./async-ast");
|
|
4
|
+
const { ruleSuppression } = require("../rule-suppression");
|
|
5
|
+
const { configCall, overrideCall } = require("./vitest-timeout-bindings");
|
|
6
|
+
const { createEvaluator } = require("./vitest-timeout-values");
|
|
7
|
+
module.exports = rule(
|
|
8
|
+
{
|
|
9
|
+
type: "problem",
|
|
10
|
+
docs: {
|
|
11
|
+
description: "cap Vitest project defaults and test/hook timeout overrides",
|
|
12
|
+
recommended: false,
|
|
13
|
+
},
|
|
14
|
+
schema: [
|
|
15
|
+
{
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
defaultMax: { type: "number", minimum: 0, exclusiveMinimum: true },
|
|
19
|
+
overrideMax: { type: "number", minimum: 0, exclusiveMinimum: true },
|
|
20
|
+
unknownValues: { enum: ["ignore", "finding"] },
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
messages: {
|
|
26
|
+
timeout:
|
|
27
|
+
"Vitest {{name}} exceeds {{max}} ms. Fix the slow wait or test setup rather than hiding hangs with a larger timeout.",
|
|
28
|
+
unknown:
|
|
29
|
+
"Vitest {{name}} cannot be statically resolved against the {{max}} ms cap. Use a same-module numeric constant or explicitly ignore unknown values.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
(context) => {
|
|
33
|
+
const option = options(context);
|
|
34
|
+
for (const name of ["defaultMax", "overrideMax"]) {
|
|
35
|
+
if (option[name] !== undefined && (!Number.isFinite(option[name]) || option[name] <= 0))
|
|
36
|
+
throw new Error(`vitest-timeout-cap ${name} must be a positive finite number`);
|
|
37
|
+
}
|
|
38
|
+
const suppressed = ruleSuppression(context, "vitest-timeout-cap");
|
|
39
|
+
const consumed = new Set();
|
|
40
|
+
const evaluate = createEvaluator(context, consumed);
|
|
41
|
+
const configs = [];
|
|
42
|
+
const overrides = [];
|
|
43
|
+
const exports = [];
|
|
44
|
+
function check(entry, name, max, origin) {
|
|
45
|
+
const node = origin || entry.origin;
|
|
46
|
+
if (suppressed(node)) return;
|
|
47
|
+
if (entry.value.kind === "number") {
|
|
48
|
+
if (entry.value.value > max)
|
|
49
|
+
context.report({ node, messageId: "timeout", data: { name, max } });
|
|
50
|
+
} else if (option.unknownValues === "finding")
|
|
51
|
+
context.report({ node, messageId: "unknown", data: { name, max } });
|
|
52
|
+
}
|
|
53
|
+
function properties(value, names, max, origin) {
|
|
54
|
+
if (value.kind !== "object") return;
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const entry = value.properties.get(name);
|
|
57
|
+
if (entry) check(entry, name, max, origin);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function defaults(value, project) {
|
|
61
|
+
if (value.kind !== "object") return;
|
|
62
|
+
const test = value.properties.get("test")?.value;
|
|
63
|
+
if (!test) return;
|
|
64
|
+
properties(test, ["testTimeout", "hookTimeout"], option.defaultMax ?? 5000, project);
|
|
65
|
+
const projects = test.kind === "object" ? test.properties.get("projects")?.value : null;
|
|
66
|
+
if (projects?.kind === "array")
|
|
67
|
+
for (const entry of projects.entries) defaults(entry.value, entry.origin);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ExportDefaultDeclaration(node) {
|
|
71
|
+
exports.push(node.declaration);
|
|
72
|
+
},
|
|
73
|
+
CallExpression(node) {
|
|
74
|
+
if (configCall(node, context)) configs.push(node);
|
|
75
|
+
const kind = overrideCall(node, context);
|
|
76
|
+
if (kind) overrides.push({ node, kind });
|
|
77
|
+
},
|
|
78
|
+
"Program:exit"() {
|
|
79
|
+
// Evaluate exports and outer merges first so overwritten fragment values never report.
|
|
80
|
+
for (const node of exports) {
|
|
81
|
+
const count = consumed.size;
|
|
82
|
+
const value = evaluate(node);
|
|
83
|
+
if (consumed.size > count) defaults(value);
|
|
84
|
+
}
|
|
85
|
+
const roots = configs.filter(
|
|
86
|
+
(node) =>
|
|
87
|
+
!configs.some(
|
|
88
|
+
(other) =>
|
|
89
|
+
other !== node && other.range[0] < node.range[0] && other.range[1] > node.range[1],
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
for (const node of roots.toReversed()) if (!consumed.has(node)) defaults(evaluate(node));
|
|
93
|
+
for (const { node, kind } of overrides) {
|
|
94
|
+
const max = option.overrideMax ?? 30000;
|
|
95
|
+
if (kind === "runtime") {
|
|
96
|
+
properties(
|
|
97
|
+
evaluate(node.arguments[0]),
|
|
98
|
+
["testTimeout", "hookTimeout"],
|
|
99
|
+
max,
|
|
100
|
+
node.arguments[0]?.type === "Identifier" ? node.arguments[0] : undefined,
|
|
101
|
+
);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const args = kind === "hook" ? node.arguments.slice(1) : node.arguments.slice(1, 3);
|
|
105
|
+
for (const argument of args) {
|
|
106
|
+
const value = evaluate(argument);
|
|
107
|
+
if (value.kind === "object")
|
|
108
|
+
properties(
|
|
109
|
+
value,
|
|
110
|
+
["timeout"],
|
|
111
|
+
max,
|
|
112
|
+
argument.type === "Identifier" ? argument : undefined,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const last = node.arguments.at(-1);
|
|
116
|
+
const minimum = kind === "hook" ? 2 : 3;
|
|
117
|
+
if (!last || node.arguments.length < minimum || isFunction(last)) continue;
|
|
118
|
+
// An options object followed by a callback is not a trailing timeout.
|
|
119
|
+
if (evaluate(node.arguments.at(-2)).kind === "object") continue;
|
|
120
|
+
const value = evaluate(last);
|
|
121
|
+
if (value.kind !== "object") check({ value, origin: last }, "timeout", max);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { resolveVariable } = require("./test-no-shared-state-aliases");
|
|
3
|
+
const { unwrapExpression } = require("./async-ast");
|
|
4
|
+
const { configCall } = require("./vitest-timeout-bindings");
|
|
5
|
+
const UNKNOWN = { kind: "unknown" };
|
|
6
|
+
function taint(value) {
|
|
7
|
+
if (value.kind === "object")
|
|
8
|
+
return {
|
|
9
|
+
...value,
|
|
10
|
+
properties: new Map(
|
|
11
|
+
[...value.properties].map(([name, entry]) => [
|
|
12
|
+
name,
|
|
13
|
+
{ ...entry, value: taint(entry.value) },
|
|
14
|
+
]),
|
|
15
|
+
),
|
|
16
|
+
};
|
|
17
|
+
if (value.kind === "array")
|
|
18
|
+
return {
|
|
19
|
+
...value,
|
|
20
|
+
entries: value.entries.map((entry) => ({ ...entry, value: taint(entry.value) })),
|
|
21
|
+
};
|
|
22
|
+
return UNKNOWN;
|
|
23
|
+
}
|
|
24
|
+
function merge(left, right) {
|
|
25
|
+
if (right.kind === "null") return left;
|
|
26
|
+
if (right.kind === "unknown") return taint(left);
|
|
27
|
+
if (left.kind !== "object" || right.kind !== "object") return right;
|
|
28
|
+
const properties = new Map((right.unknown ? taint(left) : left).properties);
|
|
29
|
+
for (const [name, entry] of right.properties) {
|
|
30
|
+
if (entry.value.kind === "null") continue;
|
|
31
|
+
const previous = properties.get(name);
|
|
32
|
+
let value = entry.value;
|
|
33
|
+
if (previous && value.kind === "unknown") value = taint(previous.value);
|
|
34
|
+
if (previous?.value.kind === "object" && value.kind === "object")
|
|
35
|
+
value = merge(previous.value, value);
|
|
36
|
+
if (previous?.value.kind === "array" && value.kind === "array")
|
|
37
|
+
value = { kind: "array", entries: [...previous.value.entries, ...value.entries] };
|
|
38
|
+
properties.set(name, { ...entry, value });
|
|
39
|
+
}
|
|
40
|
+
return { kind: "object", properties };
|
|
41
|
+
}
|
|
42
|
+
function createEvaluator(context, consumed) {
|
|
43
|
+
const evaluating = new Set();
|
|
44
|
+
const memo = new WeakMap();
|
|
45
|
+
function evaluate(node) {
|
|
46
|
+
if (!node) return UNKNOWN;
|
|
47
|
+
node = unwrapExpression(node);
|
|
48
|
+
if (memo.has(node)) return memo.get(node);
|
|
49
|
+
if (evaluating.has(node)) return UNKNOWN;
|
|
50
|
+
evaluating.add(node);
|
|
51
|
+
const value = resolve(node);
|
|
52
|
+
evaluating.delete(node);
|
|
53
|
+
memo.set(node, value);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
function resolve(node) {
|
|
57
|
+
if (node.type === "Literal" && node.value === null) return { kind: "null" };
|
|
58
|
+
if (node.type === "Literal")
|
|
59
|
+
return typeof node.value === "number" ? { kind: "number", value: node.value } : UNKNOWN;
|
|
60
|
+
if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
|
|
61
|
+
const value = evaluate(node.argument);
|
|
62
|
+
return value.kind === "number"
|
|
63
|
+
? { kind: "number", value: node.operator === "-" ? -value.value : value.value }
|
|
64
|
+
: UNKNOWN;
|
|
65
|
+
}
|
|
66
|
+
if (node.type === "Identifier") {
|
|
67
|
+
const variable = resolveVariable(node, context);
|
|
68
|
+
const def = variable?.defs[0];
|
|
69
|
+
// Only module constants: function-local assignments must not become config defaults.
|
|
70
|
+
return def?.type === "Variable" &&
|
|
71
|
+
def.parent.kind === "const" &&
|
|
72
|
+
variable.scope.type === "module"
|
|
73
|
+
? evaluate(def.node.init)
|
|
74
|
+
: UNKNOWN;
|
|
75
|
+
}
|
|
76
|
+
if (node.type === "ObjectExpression") {
|
|
77
|
+
const properties = new Map();
|
|
78
|
+
let unknown = false;
|
|
79
|
+
for (const property of node.properties) {
|
|
80
|
+
if (property.type === "SpreadElement") {
|
|
81
|
+
const spread = evaluate(property.argument);
|
|
82
|
+
if (spread.kind === "object") {
|
|
83
|
+
if (spread.unknown) {
|
|
84
|
+
unknown = true;
|
|
85
|
+
for (const [key, entry] of properties)
|
|
86
|
+
properties.set(key, { ...entry, value: taint(entry.value) });
|
|
87
|
+
}
|
|
88
|
+
for (const [key, entry] of spread.properties)
|
|
89
|
+
properties.set(key, { ...entry, origin: property });
|
|
90
|
+
} else if (spread.kind !== "null") {
|
|
91
|
+
unknown = true;
|
|
92
|
+
for (const [key, entry] of properties)
|
|
93
|
+
properties.set(key, { ...entry, value: taint(entry.value) });
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (property.type !== "Property") continue;
|
|
98
|
+
const name = property.computed
|
|
99
|
+
? property.key.type === "Literal"
|
|
100
|
+
? String(property.key.value)
|
|
101
|
+
: null
|
|
102
|
+
: (property.key.name ?? String(property.key.value));
|
|
103
|
+
if (name !== null)
|
|
104
|
+
properties.set(name, {
|
|
105
|
+
value: property.kind === "init" ? evaluate(property.value) : UNKNOWN,
|
|
106
|
+
origin: property,
|
|
107
|
+
});
|
|
108
|
+
else {
|
|
109
|
+
unknown = true;
|
|
110
|
+
for (const [key, entry] of properties)
|
|
111
|
+
properties.set(key, { ...entry, value: taint(entry.value) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { kind: "object", properties, unknown };
|
|
115
|
+
}
|
|
116
|
+
if (node.type === "ArrayExpression")
|
|
117
|
+
return {
|
|
118
|
+
kind: "array",
|
|
119
|
+
entries: node.elements.flatMap((origin) => {
|
|
120
|
+
if (!origin) return [];
|
|
121
|
+
if (origin.type === "SpreadElement") {
|
|
122
|
+
const value = evaluate(origin.argument);
|
|
123
|
+
return value.kind === "array"
|
|
124
|
+
? value.entries.map((entry) => ({ ...entry, origin }))
|
|
125
|
+
: [{ value: UNKNOWN, origin }];
|
|
126
|
+
}
|
|
127
|
+
return [{ value: evaluate(origin), origin }];
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
|
|
131
|
+
if (node.body.type !== "BlockStatement") return evaluate(node.body);
|
|
132
|
+
const statements = node.body.body;
|
|
133
|
+
return statements.length === 1 && statements[0].type === "ReturnStatement"
|
|
134
|
+
? evaluate(statements[0].argument)
|
|
135
|
+
: UNKNOWN;
|
|
136
|
+
}
|
|
137
|
+
if (node.type === "CallExpression") {
|
|
138
|
+
const name = configCall(node, context);
|
|
139
|
+
if (!name) return UNKNOWN;
|
|
140
|
+
consumed.add(node);
|
|
141
|
+
const first = evaluate(node.arguments[0]);
|
|
142
|
+
return name === "mergeConfig" ? merge(first, evaluate(node.arguments[1])) : first;
|
|
143
|
+
}
|
|
144
|
+
return UNKNOWN;
|
|
145
|
+
}
|
|
146
|
+
return evaluate;
|
|
147
|
+
}
|
|
148
|
+
module.exports = { createEvaluator };
|