eslint-plugin-no-mistakes 0.53.2 → 0.54.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-no-mistakes",
3
- "version": "0.53.2",
3
+ "version": "0.54.0",
4
4
  "description": "ESLint and Oxlint rules for deterministic no-mistakes code analysis",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,20 +17,20 @@
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
20
- "scripts": {
21
- "test": "vitest run --coverage"
22
- },
23
20
  "devDependencies": {
24
21
  "@typescript-eslint/parser": "^8.67.0",
25
- "@vitest/coverage-v8": "^4.1.10",
26
- "eslint": "^10.8.1",
27
- "oxlint": "^1.78.0",
28
- "vitest": "^4.1.6"
22
+ "@vitest/coverage-v8": "^4.1.11",
23
+ "eslint": "^10.9.0",
24
+ "oxlint": "^1.80.0",
25
+ "vitest": "^4.1.11"
29
26
  },
30
27
  "peerDependencies": {
31
28
  "eslint": ">=9"
32
29
  },
33
30
  "engines": {
34
31
  "node": "^20.19.0 || ^22.13.0 || >=24"
32
+ },
33
+ "scripts": {
34
+ "test": "vitest run --coverage"
35
35
  }
36
- }
36
+ }
package/src/index.js CHANGED
@@ -22,6 +22,8 @@ const rules = {
22
22
  "playwright-literals": require("./rules/playwright-literals"),
23
23
  "playwright-naming-convention": require("./rules/playwright-naming-convention"),
24
24
  "playwright-no-empty": require("./rules/playwright-no-empty"),
25
+ "playwright-no-hoisted-unique-token": require("./rules/playwright-no-hoisted-unique-token"),
26
+ "playwright-no-raw-scroll-pagination": require("./rules/playwright-no-raw-scroll-pagination"),
25
27
  "playwright-no-set-timeout": require("./rules/playwright-no-set-timeout"),
26
28
  "playwright-prefer-get-by-test-id": require("./rules/playwright-prefer-get-by-test-id"),
27
29
  "playwright-require-exported-component-attribute": require("./rules/playwright-require-exported-component-attribute"),
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+
3
+ const { childNodes } = require("./test-no-shared-state-helpers");
4
+ const { resolveVariable } = require("./test-no-shared-state-aliases");
5
+
6
+ // The TypeScript node types that wrap a runtime value expression rather than being purely
7
+ // type-positioned; every other "TS"-prefixed node type is skipped entirely by `collectEvents`.
8
+ const TS_VALUE_WRAPPER_TYPES = new Set([
9
+ "TSAsExpression",
10
+ "TSSatisfiesExpression",
11
+ "TSNonNullExpression",
12
+ "TSInstantiationExpression",
13
+ "TSTypeAssertion",
14
+ ]);
15
+
16
+ // An Identifier assigned to (not read) by a plain `=` assignment is a write, not a hoisted-value
17
+ // reference: a hook that reassigns the tracked variable before reading it is no longer reusing the
18
+ // hoisted value.
19
+ function isWriteTarget(node) {
20
+ const parent = node.parent;
21
+ return parent?.type === "AssignmentExpression" && parent.operator === "=" && parent.left === node;
22
+ }
23
+
24
+ const KEYED_MEMBER_TYPES = new Set(["MethodDefinition", "PropertyDefinition"]);
25
+
26
+ // Skip Identifier positions that are names, not value references: the non-computed `.property`
27
+ // of a member access, a non-computed, non-shorthand object-literal `.key`, a non-computed class
28
+ // method/field `.key` (a class declared inside the hook can legally name a member after the
29
+ // tracked variable without reading it), and a plain-assignment write target.
30
+ function isReferenceIdentifier(node) {
31
+ const parent = node.parent;
32
+ if (!parent) return true;
33
+ if (parent.type === "MemberExpression" && !parent.computed && parent.property === node) {
34
+ return false;
35
+ }
36
+ if (parent.type === "Property" && !parent.computed && !parent.shorthand && parent.key === node) {
37
+ return false;
38
+ }
39
+ if (KEYED_MEMBER_TYPES.has(parent.type) && !parent.computed && parent.key === node) {
40
+ return false;
41
+ }
42
+ return !isWriteTarget(node);
43
+ }
44
+
45
+ // Collects every read and write of an Identifier reachable from `node`, in source order.
46
+ // TypeScript type-only positions (`type X = typeof suffix`, `: typeof suffix` annotations, a
47
+ // `TSInterfaceDeclaration` body, ...) are skipped entirely — the value-wrapper expressions above
48
+ // recurse into their runtime `.expression` only, never a type operand.
49
+ function collectEvents(node, results) {
50
+ if (!node || typeof node.type !== "string") return;
51
+ if (node.type.startsWith("TS")) {
52
+ if (TS_VALUE_WRAPPER_TYPES.has(node.type)) collectEvents(node.expression, results);
53
+ return;
54
+ }
55
+ if (node.type === "Identifier") {
56
+ if (isWriteTarget(node)) results.push({ node, isWrite: true });
57
+ else if (isReferenceIdentifier(node)) results.push({ node, isWrite: false });
58
+ }
59
+ for (const child of childNodes(node)) collectEvents(child, results);
60
+ }
61
+
62
+ // A write's own right-hand side evaluates in full before the assignment takes effect, so a
63
+ // self-referential write (`suffix = suffix.trim()`) still observes the pre-write, potentially
64
+ // stale value — the result carries the same collision hazard as the value it derives from and
65
+ // must not be treated as a fresh refresh of the tracked declarator.
66
+ function writeReusesTrackedValue(node, declarator, context) {
67
+ const rhsEvents = [];
68
+ collectEvents(node.parent.right, rhsEvents);
69
+ return rhsEvents.some((rhsEvent) => {
70
+ const variable = resolveVariable(rhsEvent.node, context);
71
+ return variable?.defs?.some((def) => def.node === declarator) ?? false;
72
+ });
73
+ }
74
+
75
+ module.exports = { collectEvents, writeReusesTrackedValue };
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+
3
+ const { rule } = require("../helpers");
4
+ const { isFunctionNode } = require("./test-no-shared-state-helpers");
5
+ const { hasProperty, resolveVariable } = require("./test-no-shared-state-aliases");
6
+ const {
7
+ calleeName,
8
+ importSpecifierName,
9
+ setupCallbackKind,
10
+ } = require("./test-no-shared-state-callees");
11
+ const {
12
+ collectEvents,
13
+ writeReusesTrackedValue,
14
+ } = require("./playwright-no-hoisted-unique-token-events");
15
+
16
+ const PLAYWRIGHT_PATH_PATTERN =
17
+ /(?:^|[/\\])(?:e2e|playwright)(?:[/\\]|$)|(?:^|[/\\])e2e\.(?:spec|test)\.[cm]?[jt]sx?$|\.pw\.(?:spec|test)\.[cm]?[jt]sx?$/;
18
+
19
+ const TEST_BODY_NAMES = new Set(["test", "it"]);
20
+
21
+ function isPlaywrightPath(filename) {
22
+ return PLAYWRIGHT_PATH_PATTERN.test(filename.replace(/\\/g, "/"));
23
+ }
24
+
25
+ function nearestOwnFunction(node) {
26
+ for (let current = node.parent; current && current.type !== "Program"; current = current.parent) {
27
+ if (isFunctionNode(current)) return current;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ // A declaration is already safely scoped to its own re-entry unit when the function it lives in
33
+ // is passed straight into a setup hook (`beforeAll`/`beforeEach`/`afterEach`/`afterAll`, bare or
34
+ // `test.`-qualified) or a test body (`test`/`it`, including `.only`/`.skip`-style modifiers) — but
35
+ // NOT `describe` anywhere in the callee chain (e.g. `test.describe.only`), which registers once
36
+ // and is exactly as re-entry-hazardous as module scope. `testCalleeNames` carries every local alias
37
+ // of the imported `test`/`it`/`describe` bindings, so an aliased `import { test as pw } from
38
+ // "@playwright/test"` is recognized the same as the bare names.
39
+ function isSharedScopeShield(fn, testCalleeNames) {
40
+ const parent = fn.parent;
41
+ if (!parent || parent.type !== "CallExpression") return false;
42
+ if (setupCallbackKind(parent, testCalleeNames)) return true;
43
+ const callee = parent.callee;
44
+ if (callee.type === "Identifier") return TEST_BODY_NAMES.has(callee.name);
45
+ if (callee.type === "MemberExpression" && !callee.computed) {
46
+ return TEST_BODY_NAMES.has(calleeName(callee)) && !hasProperty(callee, "describe");
47
+ }
48
+ return false;
49
+ }
50
+
51
+ // A declaration nested arbitrarily deep inside a `beforeAll` callback — even inside a local helper
52
+ // function the hook itself defines and invokes — is minted fresh on every hook re-entry, so it is
53
+ // never a hoisting hazard regardless of how many function boundaries separate it from the hook.
54
+ function isWithinBeforeAllCallback(node, testCalleeNames) {
55
+ for (let fn = nearestOwnFunction(node); fn; fn = nearestOwnFunction(fn)) {
56
+ const parent = fn.parent;
57
+ if (
58
+ parent?.type === "CallExpression" &&
59
+ setupCallbackKind(parent, testCalleeNames) === "before-once"
60
+ ) {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+
67
+ const BRANCHING_ANCESTOR_TYPES = new Set([
68
+ "IfStatement",
69
+ "ConditionalExpression",
70
+ "SwitchStatement",
71
+ "SwitchCase",
72
+ "TryStatement",
73
+ "CatchClause",
74
+ "LogicalExpression",
75
+ "ForStatement",
76
+ "ForInStatement",
77
+ "ForOfStatement",
78
+ "WhileStatement",
79
+ "DoWhileStatement",
80
+ ]);
81
+
82
+ // `collectEvents` walks the callback body in source order, not control-flow order, so a write
83
+ // nested inside a branch, loop, or logical short-circuit may never execute on a given re-entry —
84
+ // it cannot "refresh" the tracked value just because it was visited earlier in the traversal. Only
85
+ // a write with no branching ancestor between it and the callback body is unconditional.
86
+ function isUnconditionalWrite(node, callback) {
87
+ for (let current = node.parent; current && current !== callback; current = current.parent) {
88
+ if (BRANCHING_ANCESTOR_TYPES.has(current.type)) return false;
89
+ // A write inside a function nested in the callback (a locally-defined helper) only runs if
90
+ // that function is itself called — never assume the enclosing callback executes it unconditionally.
91
+ if (isFunctionNode(current) && current !== callback) return false;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ module.exports = rule(
97
+ {
98
+ type: "problem",
99
+ docs: {
100
+ description:
101
+ "disallow reading a module/describe-scope unique-token call inside a re-entrant beforeAll",
102
+ recommended: false,
103
+ },
104
+ schema: [
105
+ {
106
+ type: "object",
107
+ properties: { tokenFactories: { type: "array", items: { type: "string" } } },
108
+ },
109
+ ],
110
+ messages: {
111
+ hoisted:
112
+ "`{{name}}` is generated once by `{{factory}}()` at module/describe scope, but `beforeAll` can re-run in the same worker process with module state preserved — the hoisted value is reused unchanged on re-entry and collides with itself. Move the `{{factory}}()` call to the first statement inside this `beforeAll` instead.",
113
+ },
114
+ },
115
+ (context) => {
116
+ let isPlaywrightFile = isPlaywrightPath(context.filename);
117
+ const tokenFactories = context.options?.[0]?.tokenFactories;
118
+ if (!tokenFactories || tokenFactories.length === 0) return {};
119
+ const factoryNames = new Set(tokenFactories);
120
+
121
+ // declarator node -> factory name, for declarations not already shielded inside their own hook/test.
122
+ const candidates = new Map();
123
+ const beforeAllCallbacks = [];
124
+ // Local aliases of the imported `test`/`it`/`describe` bindings (e.g. `import { test as pw }`),
125
+ // so `pw.beforeAll(...)` is recognized the same as `test.beforeAll(...)`.
126
+ const testCalleeNames = new Set(["test", "it", "describe"]);
127
+
128
+ return {
129
+ ImportDeclaration(node) {
130
+ if (node.source.value !== "@playwright/test") return;
131
+ isPlaywrightFile = true;
132
+ for (const specifier of node.specifiers) {
133
+ if (specifier.type !== "ImportSpecifier") continue;
134
+ const imported = importSpecifierName(specifier);
135
+ if (["describe", "it", "test"].includes(imported) && specifier.local?.name) {
136
+ testCalleeNames.add(specifier.local.name);
137
+ }
138
+ }
139
+ },
140
+ VariableDeclarator(node) {
141
+ if (
142
+ node.id.type !== "Identifier" ||
143
+ node.init?.type !== "CallExpression" ||
144
+ node.init.callee.type !== "Identifier" ||
145
+ !factoryNames.has(node.init.callee.name)
146
+ ) {
147
+ return;
148
+ }
149
+ const fn = nearestOwnFunction(node);
150
+ if (
151
+ (!fn || !isSharedScopeShield(fn, testCalleeNames)) &&
152
+ !isWithinBeforeAllCallback(node, testCalleeNames)
153
+ ) {
154
+ candidates.set(node, node.init.callee.name);
155
+ }
156
+ },
157
+ CallExpression(node) {
158
+ if (setupCallbackKind(node, testCalleeNames) !== "before-once") return;
159
+ const callback = node.arguments.find((argument) => isFunctionNode(argument));
160
+ if (callback) beforeAllCallbacks.push(callback);
161
+ },
162
+ "Program:exit"() {
163
+ if (!isPlaywrightFile || candidates.size === 0) return;
164
+ for (const callback of beforeAllCallbacks) {
165
+ const events = [];
166
+ collectEvents(callback.body, events);
167
+ const refreshed = new Set();
168
+ for (const event of events) {
169
+ // A `var` redeclared in the same scope (`var suffix; var suffix = randomSuffix();`)
170
+ // is one Variable with multiple defs — the factory-call declarator may be any of
171
+ // them, not necessarily defs[0], so every def must be checked against `candidates`.
172
+ const variable = resolveVariable(event.node, context);
173
+ const declarator = variable?.defs?.find((def) => candidates.has(def.node))?.node;
174
+ if (!declarator) continue;
175
+ if (event.isWrite) {
176
+ const refreshes =
177
+ isUnconditionalWrite(event.node, callback) &&
178
+ !writeReusesTrackedValue(event.node, declarator, context);
179
+ if (refreshes) refreshed.add(declarator);
180
+ continue;
181
+ }
182
+ if (refreshed.has(declarator)) continue;
183
+ context.report({
184
+ node: event.node,
185
+ messageId: "hoisted",
186
+ data: { name: event.node.name, factory: candidates.get(declarator) },
187
+ });
188
+ }
189
+ }
190
+ },
191
+ };
192
+ },
193
+ );
@@ -0,0 +1,236 @@
1
+ "use strict";
2
+
3
+ const { rule } = require("../helpers");
4
+ const { childNodes } = require("./test-no-shared-state-helpers");
5
+ const { resolveVariable } = require("./test-no-shared-state-aliases");
6
+
7
+ const PLAYWRIGHT_PATH_PATTERN =
8
+ /(?:^|[/\\])(?:e2e|playwright)(?:[/\\]|$)|(?:^|[/\\])e2e\.(?:spec|test)\.[cm]?[jt]sx?$|\.pw\.(?:spec|test)\.[cm]?[jt]sx?$/;
9
+
10
+ const CURSOR_WAIT_PROPERTIES = new Set(["waitForRequest", "waitForResponse"]);
11
+ const RAW_SCROLL_NAMES = new Set(["scrollTo", "scrollBy", "scroll"]);
12
+ const SEARCH_PARAMS_ACCESSORS = new Set(["has", "get", "getAll"]);
13
+
14
+ // The TypeScript node types that wrap a runtime value expression rather than being purely
15
+ // type-positioned; every other "TS"-prefixed node type is skipped by `collectLiteralStrings` — a
16
+ // string literal appearing only inside a type alias or annotation is never evaluated at runtime
17
+ // and must not be mistaken for an actual cursor-param check.
18
+ const TS_VALUE_WRAPPER_TYPES = new Set([
19
+ "TSAsExpression",
20
+ "TSSatisfiesExpression",
21
+ "TSNonNullExpression",
22
+ "TSInstantiationExpression",
23
+ "TSTypeAssertion",
24
+ ]);
25
+
26
+ function isPlaywrightPath(filename) {
27
+ return PLAYWRIGHT_PATH_PATTERN.test(filename.replace(/\\/g, "/"));
28
+ }
29
+
30
+ function propertyName(node) {
31
+ if (!node) return null;
32
+ return node.type === "Literal" ? String(node.value) : node.name;
33
+ }
34
+
35
+ // `page.waitForRequest(...)` / `page.waitForResponse(...)`, matched by property name only — the
36
+ // object (page, frame, a locator, ...) doesn't matter, mirroring how `playwright-no-set-timeout`
37
+ // matches `.waitForTimeout` regardless of receiver. A statically computed access
38
+ // (`page["waitForRequest"]`) is just as unambiguous as dot access and is matched the same way.
39
+ function isCursorWaitCall(node) {
40
+ return (
41
+ node.callee.type === "MemberExpression" &&
42
+ (!node.callee.computed || node.callee.property.type === "Literal") &&
43
+ CURSOR_WAIT_PROPERTIES.has(propertyName(node.callee.property))
44
+ );
45
+ }
46
+
47
+ // `window.scrollTo(...)`/`window.scroll(...)`, bare `scrollTo(...)`/`scroll(...)`, and the
48
+ // `scrollBy` equivalents — the only imperative, position-based browser scroll APIs. A same-named
49
+ // method on any other receiver (`map.scrollTo()`, an editor or page-object helper) is not a
50
+ // browser scroll and is never matched. `scrollIntoView` is element-relative, not a pagination
51
+ // driver, and is intentionally not matched. Both the bare call and the `window`-qualified call
52
+ // only count when the receiver resolves to the global — a project's own locally-declared or
53
+ // imported `scrollTo`/`scroll`/`scrollBy` helper, or a shadowing `window` parameter (as in
54
+ // `page.evaluate((window) => window.scrollTo(...), safeScroller)`), is a different function and
55
+ // must not be misflagged.
56
+ function isRawScrollCall(node, context) {
57
+ const callee = node.callee;
58
+ if (callee.type === "Identifier") {
59
+ if (!RAW_SCROLL_NAMES.has(callee.name)) return false;
60
+ return !(resolveVariable(callee, context)?.defs?.length > 0);
61
+ }
62
+ if (
63
+ callee.type !== "MemberExpression" ||
64
+ (callee.computed && callee.property.type !== "Literal") ||
65
+ callee.object.type !== "Identifier" ||
66
+ callee.object.name !== "window" ||
67
+ !RAW_SCROLL_NAMES.has(propertyName(callee.property))
68
+ ) {
69
+ return false;
70
+ }
71
+ return !(resolveVariable(callee.object, context)?.defs?.length > 0);
72
+ }
73
+
74
+ // `<something>.searchParams.has("after")` / `.get("after")` / `.getAll("after")` — the cursor
75
+ // param name is passed as a bare accessor argument, never appearing with a trailing `=` the way
76
+ // it does in a raw query string, so it needs its own literal-collection path rather than the
77
+ // boundary-matched one below.
78
+ function isSearchParamsAccessorCall(node) {
79
+ return Boolean(
80
+ node.callee.type === "MemberExpression" &&
81
+ !node.callee.computed &&
82
+ SEARCH_PARAMS_ACCESSORS.has(propertyName(node.callee.property)) &&
83
+ node.callee.object.type === "MemberExpression" &&
84
+ !node.callee.object.computed &&
85
+ propertyName(node.callee.object.property) === "searchParams",
86
+ );
87
+ }
88
+
89
+ function escapeRegExp(value) {
90
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ }
92
+
93
+ // Regex-derived literals are matched as pattern *source text*, not executed, so a regex author's
94
+ // own boundary syntax (`[?&]after=`, `(?:^|[?&])after=`) leaves a `]` or `)` immediately before
95
+ // the param name instead of a literal `?`/`&`. Only these exact, known boundary constructs count
96
+ // as a separator for a regex-derived literal — accepting an arbitrary `)`/`]` before the param
97
+ // would also match an unrelated alternation like `(?:next|prev)cursor=`, which closes a group
98
+ // that has nothing to do with a query-string separator. A bare `&`, an escaped `\?`, or a `^`
99
+ // start anchor written directly (`/&after=/`, `/\?after=/`, `/^after=/`) is unambiguous — unlike
100
+ // `)`/`]`, none of these three characters ever closes an unrelated construct — so they count too.
101
+ const REGEX_BOUNDARY_SUFFIXES = [
102
+ "(?:^|[?&])",
103
+ "(?:^|[&?])",
104
+ "(?:^|\\?|&)",
105
+ "(?:^|&|\\?)",
106
+ "[?&]",
107
+ "[&?]",
108
+ "&",
109
+ "\\?",
110
+ "^",
111
+ ];
112
+
113
+ // Requires the cursor param to appear as an actual query-key boundary (`?after=`, `&after=`, or
114
+ // `after=` at the very start of the literal) rather than an unconstrained substring match, which
115
+ // would false-positive on e.g. `category=after-hours` for a configured param of `after`.
116
+ function hasQueryParamBoundary(literal, param, isRegex) {
117
+ const target = new RegExp(`${escapeRegExp(param)}=`, "g");
118
+ for (const match of literal.matchAll(target)) {
119
+ const prefix = literal.slice(0, match.index);
120
+ if (prefix.length === 0) return true;
121
+ if (isRegex) {
122
+ if (REGEX_BOUNDARY_SUFFIXES.some((suffix) => prefix.endsWith(suffix))) return true;
123
+ } else if (/[?&]$/.test(prefix)) {
124
+ return true;
125
+ }
126
+ }
127
+ return false;
128
+ }
129
+
130
+ // TypeScript type-only positions (a type alias, an interface body, a `: typeof x` annotation, ...)
131
+ // are skipped entirely — a string literal type or template literal type is never evaluated at
132
+ // runtime and must not be mistaken for an actual cursor-param check. The value-wrapper expressions
133
+ // above recurse into their runtime `.expression` only, never a type operand.
134
+ function collectLiteralStrings(node, results) {
135
+ if (!node) return;
136
+ if (node.type.startsWith("TS")) {
137
+ if (TS_VALUE_WRAPPER_TYPES.has(node.type)) collectLiteralStrings(node.expression, results);
138
+ return;
139
+ }
140
+ if (node.type === "Literal") {
141
+ if (typeof node.value === "string") results.push({ value: node.value, isRegex: false });
142
+ else if (node.regex) results.push({ value: node.regex.pattern, isRegex: true });
143
+ } else if (node.type === "TemplateElement") {
144
+ results.push({ value: node.value?.raw ?? "", isRegex: false });
145
+ }
146
+ for (const child of childNodes(node)) collectLiteralStrings(child, results);
147
+ }
148
+
149
+ // A `.searchParams.has()`/`.get()` argument is never boundary-matched (see above), so an
150
+ // interpolation-free template literal (`` `cursor` ``) is just as statically knowable as a plain
151
+ // string literal — only a template with actual `${...}` interpolation is genuinely dynamic.
152
+ function staticStringValue(node) {
153
+ if (node?.type === "Literal" && typeof node.value === "string") return node.value;
154
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
155
+ return node.quasis[0].value.raw;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function collectSearchParamsAccessorArgs(node, results) {
161
+ if (!node) return;
162
+ if (node.type === "CallExpression" && isSearchParamsAccessorCall(node)) {
163
+ const value = staticStringValue(node.arguments[0]);
164
+ if (value !== null) results.push(value);
165
+ }
166
+ for (const child of childNodes(node)) collectSearchParamsAccessorArgs(child, results);
167
+ }
168
+
169
+ function mentionsCursorParam(node, cursorParams) {
170
+ if (!node) return false;
171
+ const literals = [];
172
+ collectLiteralStrings(node, literals);
173
+ if (
174
+ literals.some(({ value, isRegex }) =>
175
+ cursorParams.some((param) => hasQueryParamBoundary(value, param, isRegex)),
176
+ )
177
+ ) {
178
+ return true;
179
+ }
180
+ const accessorArgs = [];
181
+ collectSearchParamsAccessorArgs(node, accessorArgs);
182
+ return accessorArgs.some((value) => cursorParams.includes(value));
183
+ }
184
+
185
+ module.exports = rule(
186
+ {
187
+ type: "problem",
188
+ docs: {
189
+ description:
190
+ "disallow driving cursor-paginated infinite scroll with a raw window.scrollTo/scroll/scrollBy",
191
+ recommended: false,
192
+ },
193
+ schema: [
194
+ {
195
+ type: "object",
196
+ properties: {
197
+ cursorParams: { type: "array", items: { type: "string" } },
198
+ scrollHelper: { type: "string" },
199
+ },
200
+ },
201
+ ],
202
+ messages: {
203
+ rawScroll:
204
+ "This file awaits a cursor-paginated request/response but drives scrolling with a raw scrollTo/scroll/scrollBy call — a single synthetic scroll can land before a deferred IntersectionObserver mounts and be lost forever, stalling the wait for its full timeout.{{helperHint}}",
205
+ },
206
+ },
207
+ (context) => {
208
+ let isPlaywrightFile = isPlaywrightPath(context.filename);
209
+ const options = context.options?.[0] ?? {};
210
+ const cursorParams = options.cursorParams ?? ["after", "cursor"];
211
+ const scrollHelper = options.scrollHelper ?? "";
212
+ const helperHint = scrollHelper
213
+ ? ` Use ${scrollHelper}, which scrolls repeatedly until the request fires, instead.`
214
+ : " Use a helper that scrolls repeatedly until the request fires instead of a single raw call.";
215
+ let hasCursorWait = false;
216
+ const scrollCandidates = [];
217
+
218
+ return {
219
+ ImportDeclaration(node) {
220
+ if (node.source.value === "@playwright/test") isPlaywrightFile = true;
221
+ },
222
+ CallExpression(node) {
223
+ if (isCursorWaitCall(node) && mentionsCursorParam(node.arguments[0], cursorParams)) {
224
+ hasCursorWait = true;
225
+ }
226
+ if (isRawScrollCall(node, context)) scrollCandidates.push(node);
227
+ },
228
+ "Program:exit"() {
229
+ if (!isPlaywrightFile || !hasCursorWait) return;
230
+ for (const node of scrollCandidates) {
231
+ context.report({ node, messageId: "rawScroll", data: { helperHint } });
232
+ }
233
+ },
234
+ };
235
+ },
236
+ );
@@ -37,10 +37,7 @@ function createImportedTestAliases(context) {
37
37
  return Boolean(imported && resolveVariable(base, context) !== imported);
38
38
  },
39
39
  hasProperty(callee, name) {
40
- if (hasOwnProperty(callee, name)) return true;
41
- if (callee?.type === "MemberExpression") return this.hasProperty(callee.object, name);
42
- if (callee?.type === "CallExpression") return this.hasProperty(callee.callee, name);
43
- return false;
40
+ return hasProperty(callee, name);
44
41
  },
45
42
  };
46
43
  }
@@ -49,8 +46,18 @@ function hasOwnProperty(callee, name) {
49
46
  return callee?.type === "MemberExpression" && !callee.computed && callee.property?.name === name;
50
47
  }
51
48
 
49
+ // Recursively checks the whole callee chain (member accesses and call results) for a
50
+ // non-computed property named `name`, e.g. `hasProperty(test.describe.only, "describe")`.
51
+ function hasProperty(callee, name) {
52
+ if (hasOwnProperty(callee, name)) return true;
53
+ if (callee?.type === "MemberExpression") return hasProperty(callee.object, name);
54
+ if (callee?.type === "CallExpression") return hasProperty(callee.callee, name);
55
+ return false;
56
+ }
57
+
52
58
  module.exports = {
53
59
  createImportedTestAliases,
54
60
  hasOwnProperty,
61
+ hasProperty,
55
62
  resolveVariable,
56
63
  };