eslint-plugin-no-mistakes 0.8.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-no-mistakes",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "ESLint and Oxlint rules for deterministic no-mistakes code analysis",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+
3
+ function nonEmptyStrings(value, fallback) {
4
+ if (!Array.isArray(value)) {
5
+ return fallback;
6
+ }
7
+ const strings = value.filter((item) => typeof item === "string" && item.length > 0);
8
+ return strings.length > 0 ? strings : fallback;
9
+ }
10
+
11
+ function functionFromExpression(node, opts) {
12
+ const unwrapped = unwrapExpression(node, opts);
13
+ return isFunctionNode(unwrapped) ? unwrapped : null;
14
+ }
15
+
16
+ function unwrapExpression(node, opts) {
17
+ if (!node) {
18
+ return null;
19
+ }
20
+ if (isExpressionWrapper(node)) {
21
+ return unwrapExpression(node.expression, opts);
22
+ }
23
+ if (node.type !== "CallExpression" || !isConfiguredWrapper(node.callee, opts)) {
24
+ return node;
25
+ }
26
+ for (const arg of node.arguments) {
27
+ const unwrapped = unwrapExpression(arg, opts);
28
+ if (isFunctionNode(unwrapped)) {
29
+ return unwrapped;
30
+ }
31
+ }
32
+ return node;
33
+ }
34
+
35
+ function isConfiguredWrapper(callee, opts) {
36
+ const name = calleeName(callee);
37
+ return Boolean(name && opts.wrappers.has(name));
38
+ }
39
+
40
+ function calleeName(node) {
41
+ if (node.type === "Identifier") {
42
+ return node.name;
43
+ }
44
+ if (node.type === "MemberExpression" && !node.computed) {
45
+ return node.property.name;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function isFunctionNode(node) {
51
+ return Boolean(
52
+ node &&
53
+ (node.type === "FunctionDeclaration" ||
54
+ node.type === "FunctionExpression" ||
55
+ node.type === "ArrowFunctionExpression"),
56
+ );
57
+ }
58
+
59
+ function isExpressionWrapper(node) {
60
+ return [
61
+ "ChainExpression",
62
+ "ParenthesizedExpression",
63
+ "TSAsExpression",
64
+ "TSSatisfiesExpression",
65
+ "TSNonNullExpression",
66
+ "TSTypeAssertion",
67
+ "TypeCastExpression",
68
+ ].includes(node.type);
69
+ }
70
+
71
+ module.exports = {
72
+ functionFromExpression,
73
+ isExpressionWrapper,
74
+ isFunctionNode,
75
+ nonEmptyStrings,
76
+ };
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+
3
+ const { functionFromExpression, nonEmptyStrings } = require("./component-functions");
4
+
5
+ const DEFAULT_COMPONENT_NAME_PATTERN = "^[A-Z]";
6
+ const DEFAULT_WRAPPERS = ["memo", "forwardRef", "observer"];
7
+ const DEFAULT_EXPORT_TYPES = ["named", "default"];
8
+
9
+ function normalizedComponentOptions(option) {
10
+ return {
11
+ componentNamePattern: compilePattern(
12
+ option.componentNamePattern,
13
+ DEFAULT_COMPONENT_NAME_PATTERN,
14
+ ),
15
+ components: compileMatchers(option.components),
16
+ ignoreComponents: compileMatchers(option.ignoreComponents),
17
+ wrappers: new Set(nonEmptyStrings(option.wrappers, DEFAULT_WRAPPERS)),
18
+ exportTypes: new Set(nonEmptyStrings(option.exportTypes, DEFAULT_EXPORT_TYPES)),
19
+ checkAnonymousDefault: option.checkAnonymousDefault === true,
20
+ };
21
+ }
22
+
23
+ function compilePattern(value, fallback) {
24
+ return new RegExp(typeof value === "string" && value.length > 0 ? value : fallback);
25
+ }
26
+
27
+ function compileMatchers(values) {
28
+ if (!Array.isArray(values)) {
29
+ return [];
30
+ }
31
+ return values
32
+ .filter((value) => typeof value === "string" && value.length > 0)
33
+ .map((value) => {
34
+ const regex = regexLiteral(value);
35
+ return regex ? { regex } : { exact: value };
36
+ });
37
+ }
38
+
39
+ function regexLiteral(value) {
40
+ if (!value.startsWith("/") || value.lastIndexOf("/") === 0) {
41
+ return null;
42
+ }
43
+ const lastSlash = value.lastIndexOf("/");
44
+ return new RegExp(value.slice(1, lastSlash), value.slice(lastSlash + 1));
45
+ }
46
+
47
+ function shouldCheckComponent(component, opts) {
48
+ if (component.anonymousDefault) {
49
+ return opts.checkAnonymousDefault;
50
+ }
51
+ if (opts.ignoreComponents.some((matcher) => matchesName(component.name, matcher))) {
52
+ return false;
53
+ }
54
+ if (opts.components.length > 0) {
55
+ return opts.components.some((matcher) => matchesName(component.name, matcher));
56
+ }
57
+ return opts.componentNamePattern.test(component.name);
58
+ }
59
+
60
+ function matchesName(name, matcher) {
61
+ return matcher.regex ? matcher.regex.test(name) : matcher.exact === name;
62
+ }
63
+
64
+ function collectExportedComponents(program, opts) {
65
+ const definitions = new Map();
66
+ const namedExports = new Set();
67
+ const defaultExports = new Set();
68
+ const components = [];
69
+
70
+ for (const statement of program.body) {
71
+ collectTopLevelDefinition(statement, definitions, opts);
72
+ if (statement.type === "ExportNamedDeclaration") {
73
+ collectNamedExport(statement, definitions, namedExports, defaultExports, components, opts);
74
+ }
75
+ if (statement.type === "ExportDefaultDeclaration") {
76
+ collectDefaultExport(statement, definitions, defaultExports, components, opts);
77
+ }
78
+ }
79
+
80
+ pushExportedDefinitions(namedExports, definitions, components, opts.exportTypes.has("named"));
81
+ pushExportedDefinitions(defaultExports, definitions, components, opts.exportTypes.has("default"));
82
+ return uniqueComponents(components);
83
+ }
84
+
85
+ function pushExportedDefinitions(names, definitions, components, enabled) {
86
+ if (!enabled) {
87
+ return;
88
+ }
89
+ for (const name of names) {
90
+ const definition = definitions.get(name);
91
+ if (definition) {
92
+ components.push(definition);
93
+ }
94
+ }
95
+ }
96
+
97
+ function collectTopLevelDefinition(statement, definitions, opts) {
98
+ if (statement.type === "FunctionDeclaration" && statement.id) {
99
+ definitions.set(statement.id.name, { name: statement.id.name, fn: statement });
100
+ return;
101
+ }
102
+ if (statement.type !== "VariableDeclaration") {
103
+ return;
104
+ }
105
+ for (const declaration of statement.declarations) {
106
+ if (declaration.id.type !== "Identifier") {
107
+ continue;
108
+ }
109
+ const fn = functionFromExpression(declaration.init, opts);
110
+ if (fn) {
111
+ definitions.set(declaration.id.name, { name: declaration.id.name, fn });
112
+ }
113
+ }
114
+ }
115
+
116
+ function collectNamedExport(
117
+ statement,
118
+ definitions,
119
+ namedExports,
120
+ defaultExports,
121
+ components,
122
+ opts,
123
+ ) {
124
+ if (statement.source) {
125
+ return;
126
+ }
127
+ if (statement.declaration?.type === "FunctionDeclaration" && statement.declaration.id) {
128
+ const name = statement.declaration.id.name;
129
+ definitions.set(name, { name, fn: statement.declaration });
130
+ if (opts.exportTypes.has("named")) {
131
+ components.push(definitions.get(name));
132
+ }
133
+ return;
134
+ }
135
+ if (statement.declaration?.type === "VariableDeclaration") {
136
+ collectTopLevelDefinition(statement.declaration, definitions, opts);
137
+ for (const declaration of statement.declaration.declarations) {
138
+ const definition = definitions.get(declaration.id.name);
139
+ if (declaration.id.type === "Identifier" && definition && opts.exportTypes.has("named")) {
140
+ components.push(definition);
141
+ }
142
+ }
143
+ return;
144
+ }
145
+ for (const specifier of statement.specifiers || []) {
146
+ if (specifier.local?.type === "Identifier") {
147
+ if (specifier.exported?.name === "default") {
148
+ defaultExports.add(specifier.local.name);
149
+ } else {
150
+ namedExports.add(specifier.local.name);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ function collectDefaultExport(statement, definitions, defaultExports, components, opts) {
157
+ if (!opts.exportTypes.has("default")) {
158
+ return;
159
+ }
160
+ const declaration = statement.declaration;
161
+ if (declaration.type === "Identifier") {
162
+ defaultExports.add(declaration.name);
163
+ return;
164
+ }
165
+ if (declaration.type === "FunctionDeclaration") {
166
+ const name = declaration.id?.name || "default";
167
+ if (declaration.id || opts.checkAnonymousDefault) {
168
+ components.push({ name, fn: declaration, anonymousDefault: !declaration.id });
169
+ }
170
+ return;
171
+ }
172
+ const fn = functionFromExpression(declaration, opts);
173
+ if (fn && opts.checkAnonymousDefault) {
174
+ components.push({ name: fn.id?.name || "default", fn, anonymousDefault: !fn.id });
175
+ }
176
+ if (fn?.id) {
177
+ definitions.set(fn.id.name, { name: fn.id.name, fn });
178
+ defaultExports.add(fn.id.name);
179
+ }
180
+ }
181
+
182
+ function uniqueComponents(components) {
183
+ const seen = new Set();
184
+ return components.filter((component) => {
185
+ const key = `${component.name}:${component.fn.range?.[0] ?? component.fn.loc?.start?.line ?? ""}`;
186
+ if (seen.has(key)) {
187
+ return false;
188
+ }
189
+ seen.add(key);
190
+ return true;
191
+ });
192
+ }
193
+
194
+ module.exports = {
195
+ collectExportedComponents,
196
+ normalizedComponentOptions,
197
+ shouldCheckComponent,
198
+ };
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ const rules = {
18
18
  "playwright-no-empty": require("./rules/playwright-no-empty"),
19
19
  "playwright-no-set-timeout": require("./rules/playwright-no-set-timeout"),
20
20
  "playwright-prefer-get-by-test-id": require("./rules/playwright-prefer-get-by-test-id"),
21
+ "playwright-require-exported-component-attribute": require("./rules/playwright-require-exported-component-attribute"),
21
22
  "playwright-require-interactive-test-id": require("./rules/playwright-require-interactive-test-id"),
22
23
  "playwright-selector-priority": require("./rules/playwright-selector-priority"),
23
24
  "react-no-iife-in-jsx": require("./rules/react-no-iife-in-jsx"),
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+
3
+ const { attributeName } = require("./helpers");
4
+ const { isExpressionWrapper, isFunctionNode } = require("./component-functions");
5
+
6
+ function returnedJsxBranches(fn) {
7
+ if (fn.type === "ArrowFunctionExpression" && fn.body.type !== "BlockStatement") {
8
+ return jsxBranches(fn.body);
9
+ }
10
+ const branches = [];
11
+ collectReturnBranches(fn.body, branches);
12
+ return branches;
13
+ }
14
+
15
+ function collectReturnBranches(node, branches) {
16
+ if (!node || (node.type !== "BlockStatement" && isFunctionNode(node))) {
17
+ return;
18
+ }
19
+ if (node.type === "ReturnStatement") {
20
+ branches.push(...jsxBranches(node.argument));
21
+ return;
22
+ }
23
+ if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
24
+ return;
25
+ }
26
+ for (const child of childNodes(node)) {
27
+ collectReturnBranches(child, branches);
28
+ }
29
+ }
30
+
31
+ function jsxBranches(node) {
32
+ const unwrapped = unwrapSyntaxExpression(node);
33
+ if (!unwrapped || isNullLiteral(unwrapped)) {
34
+ return [];
35
+ }
36
+ if (isJsxNode(unwrapped)) {
37
+ return [unwrapped];
38
+ }
39
+ if (unwrapped.type === "ConditionalExpression") {
40
+ return [...jsxBranches(unwrapped.consequent), ...jsxBranches(unwrapped.alternate)];
41
+ }
42
+ if (unwrapped.type === "LogicalExpression") {
43
+ return [...jsxBranches(unwrapped.left), ...jsxBranches(unwrapped.right)];
44
+ }
45
+ if (unwrapped.type === "SequenceExpression") {
46
+ return jsxBranches(unwrapped.expressions.at(-1));
47
+ }
48
+ if (unwrapped.type === "ArrayExpression") {
49
+ return unwrapped.elements.flatMap((element) => jsxBranches(element));
50
+ }
51
+ return [];
52
+ }
53
+
54
+ function unwrapSyntaxExpression(node) {
55
+ let current = node;
56
+ while (current && isExpressionWrapper(current)) {
57
+ current = current.expression;
58
+ }
59
+ return current;
60
+ }
61
+
62
+ function isNullLiteral(node) {
63
+ return node.type === "Literal" && node.value === null;
64
+ }
65
+
66
+ function isJsxNode(node) {
67
+ return node.type === "JSXElement" || node.type === "JSXFragment";
68
+ }
69
+
70
+ function jsxTreeHasAttribute(node, opts) {
71
+ let found = false;
72
+ visitNode(node, (current) => {
73
+ if (found || current.type !== "JSXOpeningElement") {
74
+ return;
75
+ }
76
+ found = current.attributes.some((attribute) => {
77
+ if (attribute.type === "JSXSpreadAttribute") {
78
+ return opts.allowSpreadAttributes;
79
+ }
80
+ const name = attributeName(attribute);
81
+ return Boolean(name && opts.attributes.includes(name));
82
+ });
83
+ });
84
+ return found;
85
+ }
86
+
87
+ function visitNode(node, callback) {
88
+ if (!node || typeof node.type !== "string") {
89
+ return;
90
+ }
91
+ callback(node);
92
+ for (const child of childNodes(node)) {
93
+ visitNode(child, callback);
94
+ }
95
+ }
96
+
97
+ function childNodes(node) {
98
+ const children = [];
99
+ for (const [key, value] of Object.entries(node)) {
100
+ if (key === "parent" || key === "tokens" || key === "comments") {
101
+ continue;
102
+ }
103
+ if (Array.isArray(value)) {
104
+ children.push(...value.filter(isAstNode));
105
+ } else if (isAstNode(value)) {
106
+ children.push(value);
107
+ }
108
+ }
109
+ return children;
110
+ }
111
+
112
+ function isAstNode(value) {
113
+ return Boolean(value && typeof value.type === "string");
114
+ }
115
+
116
+ module.exports = {
117
+ jsxTreeHasAttribute,
118
+ returnedJsxBranches,
119
+ };
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+
3
+ const {
4
+ collectExportedComponents,
5
+ normalizedComponentOptions,
6
+ shouldCheckComponent,
7
+ } = require("../exported-components");
8
+ const { nonEmptyStrings } = require("../component-functions");
9
+ const { options, rule } = require("../helpers");
10
+ const { jsxTreeHasAttribute, returnedJsxBranches } = require("../returned-jsx");
11
+
12
+ const DEFAULT_ATTRIBUTES = ["data-pw"];
13
+
14
+ module.exports = rule(
15
+ {
16
+ type: "suggestion",
17
+ docs: {
18
+ description: "require configured attributes in exported component JSX trees",
19
+ recommended: false,
20
+ },
21
+ schema: [
22
+ {
23
+ type: "object",
24
+ properties: {
25
+ attributes: { type: "array", items: { type: "string" } },
26
+ componentNamePattern: { type: "string" },
27
+ components: { type: "array", items: { type: "string" } },
28
+ ignoreComponents: { type: "array", items: { type: "string" } },
29
+ wrappers: { type: "array", items: { type: "string" } },
30
+ allowSpreadAttributes: { type: "boolean" },
31
+ exportTypes: {
32
+ type: "array",
33
+ items: { enum: ["named", "default"] },
34
+ },
35
+ checkAnonymousDefault: { type: "boolean" },
36
+ },
37
+ additionalProperties: false,
38
+ },
39
+ ],
40
+ messages: {
41
+ missing: "Exported component '{{name}}' must return JSX containing one of: {{attributes}}.",
42
+ },
43
+ },
44
+ (context) => ({
45
+ Program(node) {
46
+ const opts = normalizedOptions(options(context));
47
+ for (const component of collectExportedComponents(node, opts)) {
48
+ reportMissingBranches(context, component, opts);
49
+ }
50
+ },
51
+ }),
52
+ );
53
+
54
+ function normalizedOptions(option) {
55
+ return {
56
+ ...normalizedComponentOptions(option),
57
+ attributes: nonEmptyStrings(option.attributes, DEFAULT_ATTRIBUTES),
58
+ allowSpreadAttributes: option.allowSpreadAttributes === true,
59
+ };
60
+ }
61
+
62
+ function reportMissingBranches(context, component, opts) {
63
+ if (shouldCheckComponent(component, opts)) {
64
+ for (const jsx of returnedJsxBranches(component.fn)) {
65
+ if (!jsxTreeHasAttribute(jsx, opts)) {
66
+ context.report({
67
+ node: jsx,
68
+ messageId: "missing",
69
+ data: {
70
+ name: component.name,
71
+ attributes: opts.attributes.join(", "),
72
+ },
73
+ });
74
+ }
75
+ }
76
+ }
77
+ }
@@ -62,6 +62,10 @@ function isStaticAnalysisPath(filename) {
62
62
  );
63
63
  }
64
64
 
65
+ function isGeneratedTestPath(filename) {
66
+ return filename.replace(/\\/g, "/").includes(".generated.");
67
+ }
68
+
65
69
  function isExpectCall(node) {
66
70
  const current = unwrap(node);
67
71
  if (current?.type !== "CallExpression") return false;
@@ -99,7 +103,7 @@ module.exports = rule(
99
103
  messages: { message: "Do not assert on err.message; check the error type or code instead." },
100
104
  },
101
105
  (context) => {
102
- if (isStaticAnalysisPath(context.filename)) return {};
106
+ if (isStaticAnalysisPath(context.filename) || isGeneratedTestPath(context.filename)) return {};
103
107
  let testDepth = 0;
104
108
  return {
105
109
  CallExpression(node) {
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+
3
+ const {
4
+ childNodes,
5
+ isCalledFunction,
6
+ isFunctionNode,
7
+ isInlineSetupCallback,
8
+ isInlineTestCallback,
9
+ } = require("./test-no-shared-state-helpers");
10
+
11
+ function isInsideUncalledNestedFunction(node, testDepth, setupDepth) {
12
+ if (testDepth === 0 && setupDepth === 0) return false;
13
+ let current = node.parent;
14
+ while (current) {
15
+ const isUncalledFunction =
16
+ isFunctionNode(current) &&
17
+ !isInlineSetupCallback(current) &&
18
+ !isInlineTestCallback(current) &&
19
+ !isCalledFunction(current);
20
+ if (isUncalledFunction) return true;
21
+ current = current.parent;
22
+ }
23
+ return false;
24
+ }
25
+
26
+ function isModuleMutable(context, mutableTopLevel, node, name) {
27
+ let scope = context.sourceCode.getScope(node);
28
+ while (scope) {
29
+ const variable = scope.variables.find((candidate) => candidate.name === name);
30
+ if (variable) {
31
+ return (
32
+ mutableTopLevel.has(variable.name) &&
33
+ (variable.scope.type === "module" || variable.scope.block.type === "Program")
34
+ );
35
+ }
36
+ scope = scope.upper;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ function isResetAssignment(node, isMutableInitializer) {
42
+ if (node.left.type === "Identifier") return isMutableInitializer(node.right);
43
+ return (
44
+ node.left.type === "MemberExpression" &&
45
+ !node.left.computed &&
46
+ node.left.property.type === "Identifier" &&
47
+ node.left.property.name === "length" &&
48
+ node.right.type === "Literal" &&
49
+ node.right.value === 0
50
+ );
51
+ }
52
+
53
+ function walkSharedMutations(node, handlers) {
54
+ if (isFunctionNode(node) && !isCalledFunction(node)) return;
55
+ if (node.type === "AssignmentExpression") {
56
+ handlers.onAssignment(node);
57
+ } else if (node.type === "UpdateExpression") {
58
+ handlers.onUpdate(node);
59
+ } else if (node.type === "CallExpression") {
60
+ handlers.onCall(node);
61
+ }
62
+ for (const child of childNodes(node)) walkSharedMutations(child, handlers);
63
+ }
64
+
65
+ function createViMockTracker(context, mutableTopLevel) {
66
+ const factoryReferences = new Set();
67
+ const capturedMutables = new Set();
68
+
69
+ function markIfCaptured(name) {
70
+ if (name.startsWith("mock") && mutableTopLevel.has(name) && factoryReferences.has(name)) {
71
+ capturedMutables.add(name);
72
+ }
73
+ }
74
+
75
+ return {
76
+ collectFactoryReferences(node) {
77
+ if (
78
+ node.callee.type !== "MemberExpression" ||
79
+ node.callee.object.type !== "Identifier" ||
80
+ node.callee.object.name !== "vi" ||
81
+ node.callee.property.type !== "Identifier" ||
82
+ node.callee.property.name !== "mock"
83
+ ) {
84
+ return;
85
+ }
86
+ const factory = node.arguments[1];
87
+ if (!isFunctionNode(factory)) return;
88
+ for (const { identifier } of context.sourceCode.getScope(factory).through) {
89
+ factoryReferences.add(identifier.name);
90
+ markIfCaptured(identifier.name);
91
+ }
92
+ },
93
+ isCaptured(name) {
94
+ return capturedMutables.has(name);
95
+ },
96
+ markIfCaptured,
97
+ };
98
+ }
99
+
100
+ function createRegistryReports(context, mutableTopLevel, cleanupTracker, isCaptured) {
101
+ const pending = [];
102
+
103
+ return {
104
+ flush() {
105
+ for (const { node, path, suiteKey } of pending) {
106
+ if (!cleanupTracker.has(path, suiteKey)) context.report({ node, messageId: "shared" });
107
+ }
108
+ },
109
+ remember(node, name, path, testDepth, setupDepth) {
110
+ if (
111
+ name &&
112
+ testDepth > 0 &&
113
+ setupDepth === 0 &&
114
+ !isCaptured(name) &&
115
+ isModuleMutable(context, mutableTopLevel, node, name)
116
+ ) {
117
+ pending.push({ node, path, suiteKey: cleanupTracker.currentSuiteKey() });
118
+ }
119
+ },
120
+ };
121
+ }
122
+
123
+ module.exports = {
124
+ createRegistryReports,
125
+ createViMockTracker,
126
+ isInsideUncalledNestedFunction,
127
+ isModuleMutable,
128
+ isResetAssignment,
129
+ walkSharedMutations,
130
+ };
@@ -23,6 +23,15 @@ function isTestCall(node) {
23
23
  return TEST_CALLEES.has(calleeName(node.callee));
24
24
  }
25
25
 
26
+ function setupCallbackKind(node) {
27
+ const name = calleeName(node.callee);
28
+ return name === "beforeEach" || name === "afterEach"
29
+ ? "per-test"
30
+ : name === "beforeAll" || name === "afterAll"
31
+ ? "once"
32
+ : null;
33
+ }
34
+
26
35
  function isSetupCall(node) {
27
36
  return SETUP_CALLEES.has(calleeName(node.callee));
28
37
  }
@@ -57,11 +66,30 @@ function propertyName(node) {
57
66
  return node.type === "Literal" ? String(node.value) : node.name;
58
67
  }
59
68
 
60
- function mutatingCallRootName(node) {
61
- return node.callee.type === "MemberExpression" &&
62
- MUTATING_METHODS.has(propertyName(node.callee.property))
63
- ? mutationRootName(node.callee.object)
64
- : null;
69
+ function mutationPath(node) {
70
+ if (node.type === "Identifier") return node.name;
71
+ if (node.type !== "MemberExpression") return null;
72
+ const objectPath = mutationPath(node.object);
73
+ const property =
74
+ node.computed && node.property.type !== "Literal" ? null : propertyName(node.property);
75
+ return objectPath && property ? `${objectPath}.${property}` : null;
76
+ }
77
+
78
+ function mutatingCallTarget(node) {
79
+ if (
80
+ node.callee.type !== "MemberExpression" ||
81
+ !MUTATING_METHODS.has(propertyName(node.callee.property))
82
+ ) {
83
+ return { name: null, path: null };
84
+ }
85
+ return {
86
+ name: mutationRootName(node.callee.object),
87
+ path: mutationPath(node.callee.object),
88
+ };
89
+ }
90
+
91
+ function mutatingCallPropertyName(node) {
92
+ return node.callee.type === "MemberExpression" ? propertyName(node.callee.property) : null;
65
93
  }
66
94
 
67
95
  function isFunctionNode(node) {
@@ -72,6 +100,10 @@ function isInlineTestCallback(node) {
72
100
  return node.parent?.type === "CallExpression" && isTestCall(node.parent);
73
101
  }
74
102
 
103
+ function isInlineSetupCallback(node) {
104
+ return node.parent?.type === "CallExpression" && isSetupCall(node.parent);
105
+ }
106
+
75
107
  function isCalledFunction(node) {
76
108
  if (node.parent?.type === "CallExpression" && node.parent.callee === node) return true;
77
109
  const declarator = node.parent?.type === "VariableDeclarator" ? node.parent : null;
@@ -116,16 +148,78 @@ function namedCallbackArgument(args) {
116
148
  }
117
149
  }
118
150
 
151
+ function firstNamedCallbackArgument(args) {
152
+ return args[0]?.type === "Identifier" ? args[0] : undefined;
153
+ }
154
+
155
+ function createCleanupTracker() {
156
+ const pathsBySuite = new Map();
157
+ const suiteStack = [];
158
+ let activeSuiteKey;
159
+ let replaySuiteKey;
160
+ let nextSuiteId = 0;
161
+
162
+ function currentSuiteKey() {
163
+ return replaySuiteKey ?? suiteStack.join("/");
164
+ }
165
+
166
+ function has(path, suiteKey) {
167
+ if (!path) return false;
168
+ for (const [cleanupSuiteKey, paths] of pathsBySuite) {
169
+ if (!paths.has(path)) continue;
170
+ if (!cleanupSuiteKey || suiteKey === cleanupSuiteKey) return true;
171
+ if (suiteKey.startsWith(`${cleanupSuiteKey}/`)) return true;
172
+ }
173
+ return false;
174
+ }
175
+
176
+ return {
177
+ beginSetup(kind, suiteKey = currentSuiteKey()) {
178
+ activeSuiteKey = kind === "per-test" ? suiteKey : undefined;
179
+ },
180
+ clearReplaySuite() {
181
+ replaySuiteKey = undefined;
182
+ },
183
+ currentSuiteKey,
184
+ endSetup() {
185
+ activeSuiteKey = undefined;
186
+ },
187
+ enterSuite() {
188
+ suiteStack.push(String(nextSuiteId++));
189
+ },
190
+ exitSuite() {
191
+ suiteStack.pop();
192
+ },
193
+ has,
194
+ remember(path) {
195
+ if (!path || activeSuiteKey === undefined) return;
196
+ const paths = pathsBySuite.get(activeSuiteKey) ?? new Set();
197
+ paths.add(path);
198
+ pathsBySuite.set(activeSuiteKey, paths);
199
+ },
200
+ setReplaySuite(suiteKey) {
201
+ replaySuiteKey = suiteKey;
202
+ },
203
+ };
204
+ }
205
+
119
206
  module.exports = {
120
207
  childNodes,
121
208
  collectPatternNames,
209
+ createCleanupTracker,
210
+ firstNamedCallbackArgument,
122
211
  isCalledFunction,
123
212
  isFunctionNode,
213
+ isInlineSetupCallback,
124
214
  isInlineTestCallback,
125
215
  isMutableInitializer,
216
+ calleeName,
126
217
  isSetupCall,
127
218
  isTestCall,
128
- mutatingCallRootName,
219
+ mutatingCallPropertyName,
220
+ mutatingCallTarget,
221
+ mutationPath,
129
222
  mutationRootName,
130
223
  namedCallbackArgument,
224
+ setupCallbackKind,
131
225
  };
@@ -2,17 +2,27 @@
2
2
 
3
3
  const { rule } = require("../helpers");
4
4
  const {
5
- childNodes,
5
+ createRegistryReports,
6
+ createViMockTracker,
7
+ isInsideUncalledNestedFunction,
8
+ isModuleMutable,
9
+ isResetAssignment,
10
+ walkSharedMutations,
11
+ } = require("./test-no-shared-state-analysis");
12
+ const {
13
+ calleeName,
6
14
  collectPatternNames,
7
- isCalledFunction,
15
+ createCleanupTracker,
16
+ firstNamedCallbackArgument,
8
17
  isFunctionNode,
9
- isInlineTestCallback,
10
18
  isMutableInitializer,
11
- isSetupCall,
12
19
  isTestCall,
13
- mutatingCallRootName,
20
+ mutatingCallPropertyName,
21
+ mutatingCallTarget,
22
+ mutationPath,
14
23
  mutationRootName,
15
24
  namedCallbackArgument,
25
+ setupCallbackKind,
16
26
  } = require("./test-no-shared-state-helpers");
17
27
 
18
28
  module.exports = rule(
@@ -27,45 +37,26 @@ module.exports = rule(
27
37
  },
28
38
  (context) => {
29
39
  const mutableTopLevel = new Set();
30
- const functionDeclarations = new Map();
31
40
  const pendingNamedCallbacks = [];
32
- const viMockFactoryReferences = new Set();
33
- const viMockCapturedMutables = new Set();
41
+ const pendingNamedSetupCallbacks = [];
42
+ const cleanupTracker = createCleanupTracker();
43
+ const viMockTracker = createViMockTracker(context, mutableTopLevel);
44
+ const registryReports = createRegistryReports(
45
+ context,
46
+ mutableTopLevel,
47
+ cleanupTracker,
48
+ (name) => viMockTracker.isCaptured(name),
49
+ );
34
50
  let testDepth = 0;
35
51
  let setupDepth = 0;
36
52
 
37
- function markViMockCapturedMutable(name) {
38
- if (
39
- name.startsWith("mock") &&
40
- mutableTopLevel.has(name) &&
41
- viMockFactoryReferences.has(name)
42
- ) {
43
- viMockCapturedMutables.add(name);
44
- }
45
- }
46
-
47
- function isModuleMutable(node, name) {
48
- let scope = context.sourceCode.getScope(node);
49
- while (scope) {
50
- const variable = scope.variables.find((candidate) => candidate.name === name);
51
- if (variable) {
52
- return (
53
- mutableTopLevel.has(variable.name) &&
54
- (variable.scope.type === "module" || variable.scope.block.type === "Program")
55
- );
56
- }
57
- scope = scope.upper;
58
- }
59
- return false;
60
- }
61
-
62
53
  function reportIfShared(node, name) {
63
54
  if (
64
55
  name &&
65
56
  testDepth > 0 &&
66
57
  setupDepth === 0 &&
67
- !viMockCapturedMutables.has(name) &&
68
- isModuleMutable(node, name)
58
+ !viMockTracker.isCaptured(name) &&
59
+ isModuleMutable(context, mutableTopLevel, node, name)
69
60
  ) {
70
61
  context.report({ node, messageId: "shared" });
71
62
  }
@@ -77,95 +68,141 @@ module.exports = rule(
77
68
  reportIfShared(node, mutationRootName(node.left));
78
69
  }
79
70
 
71
+ function rememberSetupCleanup(node, name, path) {
72
+ if (
73
+ name &&
74
+ setupDepth > 0 &&
75
+ mutableTopLevel.has(name) &&
76
+ isModuleMutable(context, mutableTopLevel, node, name)
77
+ ) {
78
+ cleanupTracker.remember(path);
79
+ }
80
+ }
81
+
82
+ function rememberCall(node) {
83
+ const { name, path } = mutatingCallTarget(node);
84
+ if (mutatingCallPropertyName(node) === "clear") {
85
+ rememberSetupCleanup(node, name, path);
86
+ }
87
+ registryReports.remember(node, name, path, testDepth, setupDepth);
88
+ }
89
+
90
+ function rememberAssignmentCleanup(node) {
91
+ if (setupDepth === 0) return;
92
+ if (!isResetAssignment(node, isMutableInitializer)) return;
93
+ if (node.left.type === "Identifier") {
94
+ rememberSetupCleanup(node, node.left.name, node.left.name);
95
+ return;
96
+ }
97
+ rememberSetupCleanup(node, mutationRootName(node.left), mutationPath(node.left.object));
98
+ }
99
+
100
+ function resolveFunctionCallback(node, callback) {
101
+ let scope = context.sourceCode.getScope(node);
102
+ while (scope) {
103
+ const variable = scope.variables.find((candidate) => candidate.name === callback.name);
104
+ const declaration = variable?.defs[0]?.node;
105
+ if (declaration?.type === "FunctionDeclaration") return declaration;
106
+ if (declaration?.type === "VariableDeclarator" && isFunctionNode(declaration.init)) {
107
+ return declaration.init;
108
+ }
109
+ scope = scope.upper;
110
+ }
111
+ return null;
112
+ }
113
+
80
114
  return {
81
115
  "Program > VariableDeclaration"(node) {
82
116
  for (const declaration of node.declarations) {
83
- if (declaration.id.type === "Identifier" && isFunctionNode(declaration.init)) {
84
- functionDeclarations.set(declaration.id.name, declaration.init);
85
- }
86
117
  if (node.kind === "const" && !isMutableInitializer(declaration.init)) continue;
87
118
  for (const name of collectPatternNames(declaration.id)) {
88
119
  mutableTopLevel.add(name);
89
- markViMockCapturedMutable(name);
120
+ viMockTracker.markIfCaptured(name);
90
121
  }
91
122
  }
92
123
  },
93
- "Program > FunctionDeclaration"(node) {
94
- if (node.id?.name) functionDeclarations.set(node.id.name, node);
95
- },
96
124
  "Program:exit"() {
125
+ for (const { declaration, suiteKey, kind } of pendingNamedSetupCallbacks) {
126
+ if (!declaration) continue;
127
+ const previousSetupDepth = setupDepth;
128
+ setupDepth = 1;
129
+ cleanupTracker.beginSetup(kind, suiteKey);
130
+ checkSharedMutations(declaration.body);
131
+ setupDepth = previousSetupDepth;
132
+ cleanupTracker.endSetup();
133
+ }
97
134
  testDepth = 1;
98
- for (const name of pendingNamedCallbacks) {
99
- const declaration = functionDeclarations.get(name);
100
- if (declaration) checkSharedMutations(declaration.body);
135
+ for (const { declaration, suiteKey } of pendingNamedCallbacks) {
136
+ if (!declaration) continue;
137
+ cleanupTracker.setReplaySuite(suiteKey);
138
+ checkSharedMutations(declaration.body);
139
+ cleanupTracker.clearReplaySuite();
101
140
  }
141
+ registryReports.flush();
102
142
  },
103
143
  CallExpression(node) {
104
- collectViMockFactoryReferences(node);
144
+ viMockTracker.collectFactoryReferences(node);
145
+ if (calleeName(node.callee) === "describe") cleanupTracker.enterSuite();
105
146
  if (isTestCall(node)) {
106
147
  testDepth += 1;
107
148
  const callback = namedCallbackArgument(node.arguments);
108
- if (callback) pendingNamedCallbacks.push(callback.name);
149
+ if (callback) {
150
+ pendingNamedCallbacks.push({
151
+ declaration: resolveFunctionCallback(node, callback),
152
+ suiteKey: cleanupTracker.currentSuiteKey(),
153
+ });
154
+ }
155
+ }
156
+ const setupKind = setupCallbackKind(node);
157
+ if (setupKind) {
158
+ const callback = firstNamedCallbackArgument(node.arguments);
159
+ if (callback) {
160
+ pendingNamedSetupCallbacks.push({
161
+ declaration: resolveFunctionCallback(node, callback),
162
+ suiteKey: cleanupTracker.currentSuiteKey(),
163
+ kind: setupKind,
164
+ });
165
+ }
166
+ }
167
+ if (setupKind) {
168
+ setupDepth += 1;
169
+ cleanupTracker.beginSetup(setupKind);
109
170
  }
110
- if (isSetupCall(node)) setupDepth += 1;
111
171
  },
112
172
  AssignmentExpression(node) {
113
- if (isInsideUncalledNestedFunction(node)) return;
173
+ if (isInsideUncalledNestedFunction(node, testDepth, setupDepth)) return;
174
+ rememberAssignmentCleanup(node);
114
175
  reportAssignment(node);
115
176
  },
116
177
  UpdateExpression(node) {
117
- if (isInsideUncalledNestedFunction(node)) return;
178
+ if (isInsideUncalledNestedFunction(node, testDepth, setupDepth)) return;
118
179
  reportIfShared(node, mutationRootName(node.argument));
119
180
  },
120
181
  "CallExpression:exit"(node) {
121
182
  if (isTestCall(node)) testDepth -= 1;
122
- if (isSetupCall(node)) setupDepth -= 1;
123
- if (isInsideUncalledNestedFunction(node)) return;
124
- reportIfShared(node, mutatingCallRootName(node));
183
+ if (setupCallbackKind(node)) {
184
+ setupDepth -= 1;
185
+ cleanupTracker.endSetup();
186
+ }
187
+ const isInsideNested = isInsideUncalledNestedFunction(node, testDepth, setupDepth);
188
+ if (!isInsideNested) {
189
+ rememberCall(node);
190
+ }
191
+ if (calleeName(node.callee) === "describe") cleanupTracker.exitSuite();
125
192
  },
126
193
  };
127
194
 
128
- function isInsideUncalledNestedFunction(node) {
129
- if (testDepth === 0) return false;
130
- let current = node.parent;
131
- while (current) {
132
- const isUncalledFunction =
133
- isFunctionNode(current) && !isInlineTestCallback(current) && !isCalledFunction(current);
134
- if (isUncalledFunction) return true;
135
- current = current.parent;
136
- }
137
- return false;
138
- }
139
-
140
195
  function checkSharedMutations(node) {
141
- if (isFunctionNode(node) && !isCalledFunction(node)) return;
142
- if (node.type === "AssignmentExpression") {
143
- reportAssignment(node);
144
- } else if (node.type === "UpdateExpression") {
145
- reportIfShared(node, mutationRootName(node.argument));
146
- } else if (node.type === "CallExpression") {
147
- reportIfShared(node, mutatingCallRootName(node));
148
- }
149
- for (const child of childNodes(node)) checkSharedMutations(child);
150
- }
151
-
152
- function collectViMockFactoryReferences(node) {
153
- if (
154
- node.callee.type !== "MemberExpression" ||
155
- node.callee.object.type !== "Identifier" ||
156
- node.callee.object.name !== "vi" ||
157
- node.callee.property.type !== "Identifier" ||
158
- node.callee.property.name !== "mock"
159
- ) {
160
- return;
161
- }
162
- const factory = node.arguments[1];
163
- if (!isFunctionNode(factory)) return;
164
- for (const { identifier } of context.sourceCode.getScope(factory).through) {
165
- const name = identifier.name;
166
- viMockFactoryReferences.add(name);
167
- markViMockCapturedMutable(name);
168
- }
196
+ walkSharedMutations(node, {
197
+ onAssignment: (assignment) => {
198
+ rememberAssignmentCleanup(assignment);
199
+ reportAssignment(assignment);
200
+ },
201
+ onCall: (call) => {
202
+ rememberCall(call);
203
+ },
204
+ onUpdate: (update) => reportIfShared(update, mutationRootName(update.argument)),
205
+ });
169
206
  }
170
207
  },
171
208
  );
@@ -12,6 +12,35 @@ function isTypeExport(node, specifier) {
12
12
  return node.exportKind === "type" || specifier.exportKind === "type";
13
13
  }
14
14
 
15
+ function pathPatterns(options) {
16
+ return (options.includePathPatterns || []).flatMap((pattern) => {
17
+ try {
18
+ return [new RegExp(pattern)];
19
+ } catch {
20
+ return [];
21
+ }
22
+ });
23
+ }
24
+
25
+ function normalizedPaths(context) {
26
+ const normalized = context.filename.replace(/\\/g, "/");
27
+ const cwd = context.cwd?.replace(/\\/g, "/");
28
+ if (cwd && normalized.startsWith(`${cwd}/`)) {
29
+ return [normalized.slice(cwd.length + 1), normalized];
30
+ }
31
+ return [normalized];
32
+ }
33
+
34
+ function shouldCheckFile(context, options, patterns) {
35
+ if ((options.includePathPatterns || []).length > 0 && patterns.length === 0) return false;
36
+ if (patterns.length === 0) return true;
37
+ return normalizedPaths(context).some((path) => patterns.some((pattern) => pattern.test(path)));
38
+ }
39
+
40
+ function isDefaultReExportAlias(node, specifier) {
41
+ return exportName(specifier.local) === "default" && Boolean(node.source);
42
+ }
43
+
15
44
  module.exports = rule(
16
45
  {
17
46
  type: "problem",
@@ -19,24 +48,41 @@ module.exports = rule(
19
48
  description: "disallow value export renaming",
20
49
  recommended: true,
21
50
  },
22
- schema: [],
23
51
  messages: {
24
52
  renamed:
25
53
  "Do not rename value exports. Export the original name or rename the declaration itself so agents can trace symbols directly.",
26
54
  },
55
+ schema: [
56
+ {
57
+ type: "object",
58
+ properties: {
59
+ allowDefaultReExports: { type: "boolean" },
60
+ includePathPatterns: { type: "array", items: { type: "string" } },
61
+ },
62
+ additionalProperties: false,
63
+ },
64
+ ],
27
65
  },
28
- (context) => ({
29
- ExportNamedDeclaration(node) {
30
- for (const specifier of node.specifiers || []) {
31
- if (specifier.type !== "ExportSpecifier" || isTypeExport(node, specifier)) {
32
- continue;
33
- }
34
- const local = exportName(specifier.local);
35
- const exported = exportName(specifier.exported);
36
- if (local && exported && local !== exported) {
37
- context.report({ node: specifier, messageId: "renamed" });
66
+ (context) => {
67
+ const options = context.options[0] || {};
68
+ const patterns = pathPatterns(options);
69
+ if (!shouldCheckFile(context, options, patterns)) return {};
70
+ return {
71
+ ExportNamedDeclaration(node) {
72
+ for (const specifier of node.specifiers || []) {
73
+ if (specifier.type !== "ExportSpecifier" || isTypeExport(node, specifier)) {
74
+ continue;
75
+ }
76
+ if (options.allowDefaultReExports && isDefaultReExportAlias(node, specifier)) {
77
+ continue;
78
+ }
79
+ const local = exportName(specifier.local);
80
+ const exported = exportName(specifier.exported);
81
+ if (local && exported && local !== exported) {
82
+ context.report({ node: specifier, messageId: "renamed" });
83
+ }
38
84
  }
39
- }
40
- },
41
- }),
85
+ },
86
+ };
87
+ },
42
88
  );
@@ -56,6 +56,8 @@ function isSameArgumentList(params, args) {
56
56
 
57
57
  function isSelfCall(node, call) {
58
58
  if (call.callee.type !== "Identifier") return false;
59
+ const wrapper = variableWrapperName(node);
60
+ if (wrapper) return wrapper === call.callee.name;
59
61
  if (node.id && node.id.name === call.callee.name) return true;
60
62
  const parent = node.parent;
61
63
  return (
@@ -66,7 +68,36 @@ function isSelfCall(node, call) {
66
68
  );
67
69
  }
68
70
 
71
+ function assignmentWrapperName(node) {
72
+ if (node.parent?.type !== "AssignmentExpression" || node.parent.right !== node) return null;
73
+ const left = node.parent.left;
74
+ if (left.type === "Identifier") return left.name;
75
+ if (left.type === "MemberExpression" && !left.computed) return identifierName(left.property);
76
+ if (left.type === "MemberExpression" && left.property.type === "Literal") {
77
+ return String(left.property.value);
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function variableWrapperName(node) {
83
+ return node.parent?.type === "VariableDeclarator" && node.parent.id.type === "Identifier"
84
+ ? node.parent.id.name
85
+ : null;
86
+ }
87
+
88
+ function wrapperName(node) {
89
+ return variableWrapperName(node) || assignmentWrapperName(node);
90
+ }
91
+
92
+ function isNamedWrapper(node) {
93
+ if (node.type === "FunctionDeclaration") {
94
+ return true;
95
+ }
96
+ return Boolean(wrapperName(node));
97
+ }
98
+
69
99
  function reportIfAlias(node, context) {
100
+ if (!isNamedWrapper(node)) return;
70
101
  const call = onlyCallExpression(node.body);
71
102
  if (!call || call.type !== "CallExpression") return;
72
103
  if (isSelfCall(node, call)) return;
@@ -5,6 +5,7 @@ const { rule } = require("../helpers");
5
5
  const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
6
6
  const MOCK_TEST_FILE_PATTERN = /\.mock\.test\.[cm]?[jt]sx?$/;
7
7
  const MOCK_METHODS = new Set([
8
+ "fn",
8
9
  "mock",
9
10
  "doMock",
10
11
  "unmock",