eslint-plugin-no-mistakes 0.76.1 → 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 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
- See the [ESLint rule index](../../docs/eslint-rules/README.md).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-no-mistakes",
3
- "version": "0.76.1",
3
+ "version": "0.77.0",
4
4
  "description": "ESLint and Oxlint rules that keep code static enough for no-mistakes analyzers and coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {
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 };
@@ -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 };