@scantrix/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1451 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.findVariableShadowing = findVariableShadowing;
7
+ exports.findCustomAssertionWrappers = findCustomAssertionWrappers;
8
+ exports.findManualContextInTestBodies = findManualContextInTestBodies;
9
+ exports.findTestDataMutations = findTestDataMutations;
10
+ exports.findMissingPlaywrightAwaits = findMissingPlaywrightAwaits;
11
+ exports.extractRelativeImportBindings = extractRelativeImportBindings;
12
+ exports.findSleepWrapperExports = findSleepWrapperExports;
13
+ exports.findClassSleepMethodExports = findClassSleepMethodExports;
14
+ exports.findDisguisedHardWaitCalls = findDisguisedHardWaitCalls;
15
+ exports.findUnguardedRetrievalMethods = findUnguardedRetrievalMethods;
16
+ exports.findUnguardedRetrievalMethodsInFunctions = findUnguardedRetrievalMethodsInFunctions;
17
+ exports.analyzePomClassStructure = analyzePomClassStructure;
18
+ exports.findLargeInlineDataLiterals = findLargeInlineDataLiterals;
19
+ /**
20
+ * AST-based rule helpers for analytical scanner rules.
21
+ *
22
+ * Uses the TypeScript Compiler API (ts.createSourceFile) for accurate
23
+ * scope-aware detection that regex cannot reliably achieve.
24
+ */
25
+ const typescript_1 = __importDefault(require("typescript"));
26
+ // ─── Shared Utilities ────────────────────────────────────────────────
27
+ function parseSource(filePath, content) {
28
+ return typescript_1.default.createSourceFile(filePath, content, typescript_1.default.ScriptTarget.Latest, true);
29
+ }
30
+ /** Check if a CallExpression matches `obj.method()` */
31
+ function isMethodCall(node, obj, method) {
32
+ const expr = node.expression;
33
+ if (!typescript_1.default.isPropertyAccessExpression(expr))
34
+ return false;
35
+ if (expr.name.text !== method)
36
+ return false;
37
+ if (typescript_1.default.isIdentifier(expr.expression)) {
38
+ return expr.expression.text === obj;
39
+ }
40
+ return false;
41
+ }
42
+ /** Check if a CallExpression matches `obj.method()` OR just `func()` */
43
+ function isCallTo(node, name) {
44
+ if (typescript_1.default.isIdentifier(node.expression)) {
45
+ return node.expression.text === name;
46
+ }
47
+ return false;
48
+ }
49
+ /** Get the root identifier from a PropertyAccessExpression chain */
50
+ function getRootIdentifier(node) {
51
+ if (typescript_1.default.isIdentifier(node))
52
+ return node.text;
53
+ if (typescript_1.default.isPropertyAccessExpression(node)) {
54
+ return getRootIdentifier(node.expression);
55
+ }
56
+ return undefined;
57
+ }
58
+ /** Get 1-based line number from AST position */
59
+ function lineOf(sf, pos) {
60
+ return sf.getLineAndCharacterOfPosition(pos).line + 1;
61
+ }
62
+ /** Get the declaration keyword text for a variable declaration */
63
+ function declKeyword(decl) {
64
+ const list = decl.parent;
65
+ if (!typescript_1.default.isVariableDeclarationList(list))
66
+ return "unknown";
67
+ if (list.flags & typescript_1.default.NodeFlags.Const)
68
+ return "const";
69
+ if (list.flags & typescript_1.default.NodeFlags.Let)
70
+ return "let";
71
+ return "var";
72
+ }
73
+ // ─── PW-STABILITY-007: Variable Shadowing Between Test Scopes ────────
74
+ /**
75
+ * Detect variable shadowing between describe-scope and nested
76
+ * test/hook callbacks. Outer `let`/`var` declarations that are
77
+ * shadowed by inner `const`/`let`/`var` of the same name cause
78
+ * the outer to stay undefined — runtime crash in afterAll.
79
+ */
80
+ function findVariableShadowing(filePath, content) {
81
+ const sf = parseSource(filePath, content);
82
+ const results = [];
83
+ function visit(node) {
84
+ // Find test.describe(..., callback)
85
+ if (typescript_1.default.isCallExpression(node) &&
86
+ isMethodCall(node, "test", "describe")) {
87
+ const callback = node.arguments[1];
88
+ if (callback &&
89
+ (typescript_1.default.isArrowFunction(callback) ||
90
+ typescript_1.default.isFunctionExpression(callback)) &&
91
+ callback.body &&
92
+ typescript_1.default.isBlock(callback.body)) {
93
+ analyzeDescribeBlock(sf, callback.body, results);
94
+ }
95
+ }
96
+ typescript_1.default.forEachChild(node, visit);
97
+ }
98
+ visit(sf);
99
+ return results;
100
+ }
101
+ function analyzeDescribeBlock(sf, block, results) {
102
+ // Collect top-level let/var declarations in describe scope
103
+ const outerVars = new Map();
104
+ for (const stmt of block.statements) {
105
+ if (typescript_1.default.isVariableStatement(stmt)) {
106
+ const keyword = declKeyword(stmt.declarationList.declarations[0]);
107
+ if (keyword === "let" || keyword === "var") {
108
+ for (const decl of stmt.declarationList.declarations) {
109
+ if (typescript_1.default.isIdentifier(decl.name)) {
110
+ const name = decl.name.text;
111
+ // Skip intentionally unused vars (prefixed with _)
112
+ if (name.startsWith("_"))
113
+ continue;
114
+ outerVars.set(name, {
115
+ line: lineOf(sf, decl.getStart()),
116
+ kind: keyword,
117
+ decl,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ if (outerVars.size === 0)
125
+ return;
126
+ // Walk nested test/hook callbacks for shadowing declarations
127
+ for (const stmt of block.statements) {
128
+ if (!typescript_1.default.isExpressionStatement(stmt))
129
+ continue;
130
+ const expr = stmt.expression;
131
+ if (!typescript_1.default.isCallExpression(expr))
132
+ continue;
133
+ // Match test(), test.only(), test.beforeAll(), test.afterAll(), etc.
134
+ const callee = expr.expression;
135
+ const isTestLikeCall = isCallTo(expr, "test") ||
136
+ (typescript_1.default.isPropertyAccessExpression(callee) &&
137
+ typescript_1.default.isIdentifier(callee.expression) &&
138
+ callee.expression.text === "test");
139
+ if (!isTestLikeCall)
140
+ continue;
141
+ // Find the callback argument (last arg that is a function)
142
+ for (const arg of expr.arguments) {
143
+ if ((typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) &&
144
+ arg.body &&
145
+ typescript_1.default.isBlock(arg.body)) {
146
+ collectShadowingInBlock(sf, arg.body, outerVars, results);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ function collectShadowingInBlock(sf, block, outerVars, results) {
152
+ function walk(node) {
153
+ if (typescript_1.default.isVariableDeclaration(node) && typescript_1.default.isIdentifier(node.name)) {
154
+ const name = node.name.text;
155
+ const outer = outerVars.get(name);
156
+ if (outer) {
157
+ const innerKind = declKeyword(node);
158
+ results.push({
159
+ file: sf.fileName,
160
+ outerLine: outer.line,
161
+ innerLine: lineOf(sf, node.getStart()),
162
+ name,
163
+ outerKind: outer.kind,
164
+ innerKind,
165
+ });
166
+ }
167
+ }
168
+ typescript_1.default.forEachChild(node, walk);
169
+ }
170
+ walk(block);
171
+ }
172
+ // ─── PW-FLAKE-007: Custom Assertion Wrappers ─────────────────────────
173
+ const ASSERTION_NAME_RE = /^(compare|assert|verify|validate|check|ensure|confirm)/i;
174
+ /**
175
+ * Detect custom assertion wrappers imported from relative paths
176
+ * that bypass Playwright's auto-retrying expect().
177
+ */
178
+ function findCustomAssertionWrappers(filePath, content) {
179
+ const sf = parseSource(filePath, content);
180
+ // 1. Collect assertion-like imports from relative paths
181
+ const assertionBindings = new Map(); // name → module path
182
+ for (const stmt of sf.statements) {
183
+ if (!typescript_1.default.isImportDeclaration(stmt))
184
+ continue;
185
+ const specifier = stmt.moduleSpecifier;
186
+ if (!typescript_1.default.isStringLiteral(specifier))
187
+ continue;
188
+ const modulePath = specifier.text;
189
+ // Only relative imports
190
+ if (!modulePath.startsWith("./") && !modulePath.startsWith("../"))
191
+ continue;
192
+ if (stmt.importClause) {
193
+ // Named imports: import { compareFoo } from './utils'
194
+ const namedBindings = stmt.importClause.namedBindings;
195
+ if (namedBindings && typescript_1.default.isNamedImports(namedBindings)) {
196
+ for (const el of namedBindings.elements) {
197
+ const name = el.name.text;
198
+ if (ASSERTION_NAME_RE.test(name)) {
199
+ assertionBindings.set(name, modulePath);
200
+ }
201
+ }
202
+ }
203
+ // Default import: import compareFoo from './utils'
204
+ if (stmt.importClause.name) {
205
+ const name = stmt.importClause.name.text;
206
+ if (ASSERTION_NAME_RE.test(name)) {
207
+ assertionBindings.set(name, modulePath);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ if (assertionBindings.size === 0)
213
+ return [];
214
+ // 2. Count calls to each assertion binding AND expect() calls AND test() blocks
215
+ const callCounts = new Map();
216
+ let expectCount = 0;
217
+ let testCount = 0;
218
+ function walk(node) {
219
+ if (typescript_1.default.isCallExpression(node)) {
220
+ // Count expect()
221
+ if (typescript_1.default.isIdentifier(node.expression) && node.expression.text === "expect") {
222
+ expectCount++;
223
+ }
224
+ // Count test() / test.only()
225
+ if (isCallTo(node, "test") || isMethodCall(node, "test", "only")) {
226
+ testCount++;
227
+ }
228
+ // Count assertion binding calls
229
+ if (typescript_1.default.isIdentifier(node.expression)) {
230
+ const name = node.expression.text;
231
+ if (assertionBindings.has(name)) {
232
+ callCounts.set(name, (callCounts.get(name) ?? 0) + 1);
233
+ }
234
+ }
235
+ // Also check await expressions: await compareFoo(...)
236
+ }
237
+ typescript_1.default.forEachChild(node, walk);
238
+ }
239
+ walk(sf);
240
+ // 3. Flag if custom calls ≥ 2 AND (expect count < test count OR custom calls > expect count)
241
+ const results = [];
242
+ for (const [name, modulePath] of assertionBindings) {
243
+ const count = callCounts.get(name) ?? 0;
244
+ if (count >= 2 && (expectCount < testCount || count > expectCount)) {
245
+ results.push({
246
+ file: filePath,
247
+ importName: name,
248
+ importPath: modulePath,
249
+ callCount: count,
250
+ expectCount,
251
+ testCount,
252
+ });
253
+ }
254
+ }
255
+ return results;
256
+ }
257
+ // ─── PW-STABILITY-008: Manual Browser Context in Test Bodies ─────────
258
+ /**
259
+ * Check if a node is inside a VariableDeclaration within the given boundary.
260
+ * Used to distinguish locally-declared contexts (const/let/var inside test body)
261
+ * from assignments to outer-scoped variables (describe-level, accessible to afterAll).
262
+ */
263
+ function isLocallyDeclared(node, boundary) {
264
+ let current = node.parent;
265
+ while (current && current !== boundary) {
266
+ if (typescript_1.default.isVariableDeclaration(current))
267
+ return true;
268
+ current = current.parent;
269
+ }
270
+ return false;
271
+ }
272
+ /**
273
+ * Detect `browser.newContext()` calls inside test() callbacks.
274
+ * Fixtures handle lifecycle automatically (including failure scenarios).
275
+ *
276
+ * Only flags calls where the context is locally declared (const/let/var)
277
+ * inside the test body. Assignments to outer-scoped variables (declared in
278
+ * describe scope) are skipped because afterAll/afterEach can access them
279
+ * for cleanup.
280
+ */
281
+ function findManualContextInTestBodies(filePath, content) {
282
+ const sf = parseSource(filePath, content);
283
+ const results = [];
284
+ function visit(node) {
285
+ if (!typescript_1.default.isCallExpression(node)) {
286
+ typescript_1.default.forEachChild(node, visit);
287
+ return;
288
+ }
289
+ // Match test(...) or test.only(...)
290
+ const isTestCall = isCallTo(node, "test") || isMethodCall(node, "test", "only");
291
+ if (!isTestCall) {
292
+ typescript_1.default.forEachChild(node, visit);
293
+ return;
294
+ }
295
+ // Skip hooks: test.beforeAll, test.afterAll, test.beforeEach, test.afterEach
296
+ if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
297
+ const method = node.expression.name.text;
298
+ if (method === "beforeAll" ||
299
+ method === "afterAll" ||
300
+ method === "beforeEach" ||
301
+ method === "afterEach" ||
302
+ method === "describe") {
303
+ typescript_1.default.forEachChild(node, visit);
304
+ return;
305
+ }
306
+ }
307
+ // Extract test name from 1st argument
308
+ let testName = "(unnamed)";
309
+ if (node.arguments.length > 0 && typescript_1.default.isStringLiteral(node.arguments[0])) {
310
+ testName = node.arguments[0].text;
311
+ }
312
+ // Find the callback (2nd or last arg that is a function)
313
+ let callback;
314
+ for (const arg of node.arguments) {
315
+ if (typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) {
316
+ callback = arg;
317
+ }
318
+ }
319
+ if (!callback || !callback.body || !typescript_1.default.isBlock(callback.body)) {
320
+ typescript_1.default.forEachChild(node, visit);
321
+ return;
322
+ }
323
+ // Count browser.newContext() and request.newContext() calls in the callback body.
324
+ // Only count calls where the result is locally declared (const/let/var) inside
325
+ // the test body. If assigned to an outer-scoped variable (describe level),
326
+ // afterAll/afterEach can clean it up — skip those.
327
+ const bodyBlock = callback.body;
328
+ let newContextCount = 0;
329
+ let firstLine = 0;
330
+ function walkBody(n) {
331
+ if (typescript_1.default.isCallExpression(n) &&
332
+ typescript_1.default.isPropertyAccessExpression(n.expression) &&
333
+ n.expression.name.text === "newContext" &&
334
+ typescript_1.default.isIdentifier(n.expression.expression) &&
335
+ (n.expression.expression.text === "browser" ||
336
+ n.expression.expression.text === "request")) {
337
+ if (isLocallyDeclared(n, bodyBlock)) {
338
+ newContextCount++;
339
+ if (firstLine === 0) {
340
+ firstLine = lineOf(sf, n.getStart());
341
+ }
342
+ }
343
+ }
344
+ typescript_1.default.forEachChild(n, walkBody);
345
+ }
346
+ walkBody(bodyBlock);
347
+ if (newContextCount > 0) {
348
+ results.push({
349
+ file: filePath,
350
+ line: firstLine,
351
+ testName,
352
+ count: newContextCount,
353
+ });
354
+ }
355
+ // Don't recurse into the test callback again (already walked)
356
+ }
357
+ visit(sf);
358
+ return results;
359
+ }
360
+ // ─── ARCH-012: Mutating Imported Test Data Objects ───────────────────
361
+ const DATA_PATH_RE = /(?:data|fixture|constant|config)/i;
362
+ /**
363
+ * Detect mutations of imported test-data objects (namespace or named imports
364
+ * from relative paths). Mutating shared module-level objects breaks
365
+ * parallel execution.
366
+ */
367
+ function findTestDataMutations(filePath, content) {
368
+ const sf = parseSource(filePath, content);
369
+ // 1. Collect import bindings
370
+ const importBindings = new Map(); // binding name → module path
371
+ for (const stmt of sf.statements) {
372
+ if (!typescript_1.default.isImportDeclaration(stmt))
373
+ continue;
374
+ const specifier = stmt.moduleSpecifier;
375
+ if (!typescript_1.default.isStringLiteral(specifier))
376
+ continue;
377
+ const modulePath = specifier.text;
378
+ // Only relative imports
379
+ if (!modulePath.startsWith("./") && !modulePath.startsWith("../"))
380
+ continue;
381
+ if (stmt.importClause) {
382
+ const namedBindings = stmt.importClause.namedBindings;
383
+ // Namespace imports: import * as testData from './data'
384
+ if (namedBindings && typescript_1.default.isNamespaceImport(namedBindings)) {
385
+ importBindings.set(namedBindings.name.text, modulePath);
386
+ }
387
+ // Named imports from data-like paths
388
+ if (namedBindings &&
389
+ typescript_1.default.isNamedImports(namedBindings) &&
390
+ DATA_PATH_RE.test(modulePath)) {
391
+ for (const el of namedBindings.elements) {
392
+ importBindings.set(el.name.text, modulePath);
393
+ }
394
+ }
395
+ // Default import from data-like paths
396
+ if (stmt.importClause.name && DATA_PATH_RE.test(modulePath)) {
397
+ importBindings.set(stmt.importClause.name.text, modulePath);
398
+ }
399
+ }
400
+ }
401
+ if (importBindings.size === 0)
402
+ return [];
403
+ // 2. Find assignments where left side is a property of an imported binding
404
+ const mutations = new Map();
405
+ function walk(node) {
406
+ if (typescript_1.default.isBinaryExpression(node) &&
407
+ node.operatorToken.kind === typescript_1.default.SyntaxKind.EqualsToken) {
408
+ const left = node.left;
409
+ if (typescript_1.default.isPropertyAccessExpression(left) || typescript_1.default.isElementAccessExpression(left)) {
410
+ const root = getRootIdentifier(left);
411
+ if (root && importBindings.has(root)) {
412
+ const entry = mutations.get(root) ?? {
413
+ importPath: importBindings.get(root),
414
+ lines: [],
415
+ snippets: [],
416
+ };
417
+ entry.lines.push(lineOf(sf, node.getStart()));
418
+ const lineText = content
419
+ .split(/\r?\n/)[lineOf(sf, node.getStart()) - 1]?.trim()
420
+ .slice(0, 120);
421
+ entry.snippets.push(lineText ?? "");
422
+ mutations.set(root, entry);
423
+ }
424
+ }
425
+ }
426
+ typescript_1.default.forEachChild(node, walk);
427
+ }
428
+ walk(sf);
429
+ // 3. Build results
430
+ const results = [];
431
+ for (const [binding, data] of mutations) {
432
+ results.push({
433
+ file: filePath,
434
+ line: data.lines[0],
435
+ bindingName: binding,
436
+ importPath: data.importPath,
437
+ mutationCount: data.lines.length,
438
+ snippet: `${data.lines.length} mutation(s) of '${binding}' from ${data.importPath}`,
439
+ });
440
+ }
441
+ return results;
442
+ }
443
+ // ─── PW-FLAKE-008: Missing await on Async Playwright APIs ───────────
444
+ const ASYNC_MATCHERS = new Set([
445
+ "toBeAttached", "toBeChecked", "toBeDisabled", "toBeEditable",
446
+ "toBeEmpty", "toBeEnabled", "toBeFocused", "toBeHidden",
447
+ "toBeInViewport", "toBeVisible", "toContainText",
448
+ "toHaveAccessibleDescription", "toHaveAccessibleName",
449
+ "toHaveAttribute", "toHaveCSS", "toHaveClass", "toHaveCount",
450
+ "toHaveId", "toHaveJSProperty", "toHaveRole", "toHaveScreenshot",
451
+ "toHaveText", "toHaveTitle", "toHaveURL", "toHaveValue",
452
+ "toHaveValues", "toPass",
453
+ ]);
454
+ /**
455
+ * Walk a call-expression chain and check whether the root is
456
+ * `expect(...)`, `expect.soft(...)`, or `expect.poll(...)`.
457
+ * Returns the kind if found, undefined otherwise.
458
+ */
459
+ function getExpectChainKind(node) {
460
+ // Walk the expression to find the root call
461
+ let current = node.expression;
462
+ // Peel off PropertyAccessExpression layers: e.g. expect(...).not.toBeVisible
463
+ while (typescript_1.default.isPropertyAccessExpression(current)) {
464
+ current = current.expression;
465
+ }
466
+ // Now `current` should be the root call: expect(...) / expect.soft(...) / expect.poll(...)
467
+ if (!typescript_1.default.isCallExpression(current))
468
+ return undefined;
469
+ const callee = current.expression;
470
+ // expect(...)
471
+ if (typescript_1.default.isIdentifier(callee) && callee.text === "expect") {
472
+ return "expect";
473
+ }
474
+ // expect.soft(...) or expect.poll(...)
475
+ if (typescript_1.default.isPropertyAccessExpression(callee) &&
476
+ typescript_1.default.isIdentifier(callee.expression) &&
477
+ callee.expression.text === "expect") {
478
+ const method = callee.name.text;
479
+ if (method === "soft")
480
+ return "expect.soft";
481
+ if (method === "poll")
482
+ return "expect.poll";
483
+ }
484
+ return undefined;
485
+ }
486
+ /**
487
+ * Get the final method name from a call expression's property chain.
488
+ * e.g. `expect(x).not.toBeVisible()` → "toBeVisible"
489
+ */
490
+ function getFinalMethodName(node) {
491
+ if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
492
+ return node.expression.name.text;
493
+ }
494
+ return undefined;
495
+ }
496
+ /** Check if a CallExpression is `test.step(...)` */
497
+ function isTestStepCall(node) {
498
+ return isMethodCall(node, "test", "step");
499
+ }
500
+ /**
501
+ * Detect missing `await` on async Playwright APIs inside test() callbacks.
502
+ *
503
+ * Catches:
504
+ * - `expect(locator).toBeVisible()` (and all 28 async matchers)
505
+ * - `expect.soft(locator).toBeVisible()` (soft assertion variants)
506
+ * - `expect.poll(() => ...).toBe(...)` (poll chains)
507
+ * - `test.step("name", async () => { ... })` (unawaited steps)
508
+ * - Negated forms: `expect(x).not.toBeVisible()`
509
+ */
510
+ function findMissingPlaywrightAwaits(filePath, content) {
511
+ const sf = parseSource(filePath, content);
512
+ const results = [];
513
+ function visit(node) {
514
+ if (!typescript_1.default.isCallExpression(node)) {
515
+ typescript_1.default.forEachChild(node, visit);
516
+ return;
517
+ }
518
+ // Match test(...) or test.only(...)
519
+ const isTestCall = isCallTo(node, "test") || isMethodCall(node, "test", "only");
520
+ if (!isTestCall) {
521
+ typescript_1.default.forEachChild(node, visit);
522
+ return;
523
+ }
524
+ // Skip hooks and describe
525
+ if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
526
+ const method = node.expression.name.text;
527
+ if (method === "beforeAll" ||
528
+ method === "afterAll" ||
529
+ method === "beforeEach" ||
530
+ method === "afterEach" ||
531
+ method === "describe") {
532
+ typescript_1.default.forEachChild(node, visit);
533
+ return;
534
+ }
535
+ }
536
+ // Extract test name
537
+ let testName = "(unnamed)";
538
+ if (node.arguments.length > 0 && typescript_1.default.isStringLiteral(node.arguments[0])) {
539
+ testName = node.arguments[0].text;
540
+ }
541
+ // Find the callback
542
+ let callback;
543
+ for (const arg of node.arguments) {
544
+ if (typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) {
545
+ callback = arg;
546
+ }
547
+ }
548
+ if (!callback || !callback.body || !typescript_1.default.isBlock(callback.body)) {
549
+ typescript_1.default.forEachChild(node, visit);
550
+ return;
551
+ }
552
+ // Walk the callback body for unawaited async patterns
553
+ function walkBody(n) {
554
+ if (typescript_1.default.isExpressionStatement(n)) {
555
+ const expr = n.expression;
556
+ // If the expression is an AwaitExpression, it's properly awaited — skip
557
+ if (typescript_1.default.isAwaitExpression(expr)) {
558
+ return;
559
+ }
560
+ if (typescript_1.default.isCallExpression(expr)) {
561
+ // Check for unawaited test.step(...)
562
+ if (isTestStepCall(expr)) {
563
+ results.push({
564
+ file: filePath,
565
+ line: lineOf(sf, n.getStart()),
566
+ testName,
567
+ snippet: "test.step(...)",
568
+ });
569
+ return;
570
+ }
571
+ // Check for unawaited expect chain with async matcher
572
+ const methodName = getFinalMethodName(expr);
573
+ if (methodName) {
574
+ const chainKind = getExpectChainKind(expr);
575
+ if (chainKind === "expect.poll") {
576
+ // Any matcher on expect.poll() is async
577
+ results.push({
578
+ file: filePath,
579
+ line: lineOf(sf, n.getStart()),
580
+ testName,
581
+ snippet: `expect.poll(...).${methodName}(...)`,
582
+ });
583
+ return;
584
+ }
585
+ if ((chainKind === "expect" || chainKind === "expect.soft") &&
586
+ ASYNC_MATCHERS.has(methodName)) {
587
+ const prefix = chainKind === "expect.soft"
588
+ ? "expect.soft(...)"
589
+ : "expect(...)";
590
+ results.push({
591
+ file: filePath,
592
+ line: lineOf(sf, n.getStart()),
593
+ testName,
594
+ snippet: `${prefix}.${methodName}(...)`,
595
+ });
596
+ return;
597
+ }
598
+ }
599
+ }
600
+ }
601
+ typescript_1.default.forEachChild(n, walkBody);
602
+ }
603
+ walkBody(callback.body);
604
+ // Don't recurse into the test callback again
605
+ }
606
+ visit(sf);
607
+ return results;
608
+ }
609
+ // ─── PW-FLAKE-001 Enhancement: Disguised Hard Wait Detection ─────────
610
+ const RETRY_INTENT_KEYWORDS = ["retry", "attempt", "backoff", "polling", "reconnect"];
611
+ /**
612
+ * Extract all named/default import bindings from relative paths.
613
+ * Skips namespace imports (`import * as ...`) and non-relative imports.
614
+ */
615
+ function extractRelativeImportBindings(filePath, content) {
616
+ const sf = parseSource(filePath, content);
617
+ const bindings = [];
618
+ for (const stmt of sf.statements) {
619
+ if (!typescript_1.default.isImportDeclaration(stmt))
620
+ continue;
621
+ const specifier = stmt.moduleSpecifier;
622
+ if (!typescript_1.default.isStringLiteral(specifier))
623
+ continue;
624
+ const modulePath = specifier.text;
625
+ if (!modulePath.startsWith("./") && !modulePath.startsWith("../"))
626
+ continue;
627
+ if (!stmt.importClause)
628
+ continue;
629
+ // Default import
630
+ if (stmt.importClause.name) {
631
+ bindings.push({
632
+ localName: stmt.importClause.name.text,
633
+ originalName: "default",
634
+ modulePath,
635
+ });
636
+ }
637
+ const namedBindings = stmt.importClause.namedBindings;
638
+ if (!namedBindings)
639
+ continue;
640
+ // Skip namespace imports (import * as ...)
641
+ if (typescript_1.default.isNamespaceImport(namedBindings))
642
+ continue;
643
+ // Named imports
644
+ if (typescript_1.default.isNamedImports(namedBindings)) {
645
+ for (const el of namedBindings.elements) {
646
+ bindings.push({
647
+ localName: el.name.text,
648
+ originalName: el.propertyName ? el.propertyName.text : el.name.text,
649
+ modulePath,
650
+ });
651
+ }
652
+ }
653
+ }
654
+ return bindings;
655
+ }
656
+ /**
657
+ * Check whether a function body contains a sleep pattern:
658
+ * - `new Promise(... => setTimeout(...))`
659
+ * - `waitForTimeout(...)` as a property access call
660
+ * - Direct `setTimeout(...)` call
661
+ */
662
+ function bodyContainsSleepPattern(body) {
663
+ let found = false;
664
+ function walk(node) {
665
+ if (found)
666
+ return;
667
+ if (typescript_1.default.isCallExpression(node)) {
668
+ // Direct setTimeout(...)
669
+ if (typescript_1.default.isIdentifier(node.expression) && node.expression.text === "setTimeout") {
670
+ found = true;
671
+ return;
672
+ }
673
+ // *.waitForTimeout(...)
674
+ if (typescript_1.default.isPropertyAccessExpression(node.expression) &&
675
+ node.expression.name.text === "waitForTimeout") {
676
+ found = true;
677
+ return;
678
+ }
679
+ // *.waitForNetworkIdle(...)
680
+ if (typescript_1.default.isPropertyAccessExpression(node.expression) &&
681
+ node.expression.name.text === "waitForNetworkIdle") {
682
+ found = true;
683
+ return;
684
+ }
685
+ // *.waitForLoadState('networkidle')
686
+ if (typescript_1.default.isPropertyAccessExpression(node.expression) &&
687
+ node.expression.name.text === "waitForLoadState" &&
688
+ node.arguments.length > 0 &&
689
+ typescript_1.default.isStringLiteral(node.arguments[0]) &&
690
+ node.arguments[0].text === "networkidle") {
691
+ found = true;
692
+ return;
693
+ }
694
+ }
695
+ // new Promise(... => setTimeout(...))
696
+ if (typescript_1.default.isNewExpression(node) &&
697
+ typescript_1.default.isIdentifier(node.expression) &&
698
+ node.expression.text === "Promise") {
699
+ const args = node.arguments;
700
+ if (args && args.length > 0) {
701
+ function walkForSetTimeout(n) {
702
+ if (found)
703
+ return;
704
+ if (typescript_1.default.isCallExpression(n) &&
705
+ typescript_1.default.isIdentifier(n.expression) &&
706
+ n.expression.text === "setTimeout") {
707
+ found = true;
708
+ return;
709
+ }
710
+ typescript_1.default.forEachChild(n, walkForSetTimeout);
711
+ }
712
+ for (const arg of args) {
713
+ walkForSetTimeout(arg);
714
+ }
715
+ }
716
+ return;
717
+ }
718
+ typescript_1.default.forEachChild(node, walk);
719
+ }
720
+ walk(body);
721
+ return found;
722
+ }
723
+ /**
724
+ * Check whether a function body contains a loop with retry-intent identifiers.
725
+ * Used to suppress false positives on retry/polling wrappers.
726
+ */
727
+ function bodyHasRetryLoop(body, sf) {
728
+ let hasLoop = false;
729
+ let hasRetryIntent = false;
730
+ function walk(node) {
731
+ if (hasLoop && hasRetryIntent)
732
+ return;
733
+ if (typescript_1.default.isWhileStatement(node) ||
734
+ typescript_1.default.isForStatement(node) ||
735
+ typescript_1.default.isForInStatement(node) ||
736
+ typescript_1.default.isForOfStatement(node) ||
737
+ typescript_1.default.isDoStatement(node)) {
738
+ hasLoop = true;
739
+ }
740
+ if (typescript_1.default.isIdentifier(node)) {
741
+ const name = node.text.toLowerCase();
742
+ if (RETRY_INTENT_KEYWORDS.some((kw) => name.includes(kw))) {
743
+ hasRetryIntent = true;
744
+ }
745
+ }
746
+ if (typescript_1.default.isStringLiteral(node)) {
747
+ const text = node.text.toLowerCase();
748
+ if (RETRY_INTENT_KEYWORDS.some((kw) => text.includes(kw))) {
749
+ hasRetryIntent = true;
750
+ }
751
+ }
752
+ typescript_1.default.forEachChild(node, walk);
753
+ }
754
+ walk(body);
755
+ return hasLoop && hasRetryIntent;
756
+ }
757
+ /**
758
+ * Parse a utility module and return names of exported functions whose body
759
+ * is a sleep wrapper (Promise-setTimeout, waitForTimeout, direct setTimeout).
760
+ * Excludes functions with retry/polling loop patterns (false-positive filter).
761
+ */
762
+ function findSleepWrapperExports(filePath, content) {
763
+ const sf = parseSource(filePath, content);
764
+ // 1. Collect all module-level function bodies by name
765
+ const funcBodies = new Map();
766
+ // 2. Collect exported names
767
+ const exportedNames = new Set();
768
+ for (const stmt of sf.statements) {
769
+ // Function declarations: function foo() { ... }
770
+ if (typescript_1.default.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
771
+ funcBodies.set(stmt.name.text, stmt.body);
772
+ if (stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.ExportKeyword)) {
773
+ exportedNames.add(stmt.name.text);
774
+ }
775
+ if (stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.DefaultKeyword)) {
776
+ exportedNames.add(stmt.name.text);
777
+ }
778
+ }
779
+ // Variable declarations: const foo = async () => { ... }
780
+ if (typescript_1.default.isVariableStatement(stmt)) {
781
+ const isExported = stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.ExportKeyword);
782
+ for (const decl of stmt.declarationList.declarations) {
783
+ if (!typescript_1.default.isIdentifier(decl.name) || !decl.initializer)
784
+ continue;
785
+ const init = decl.initializer;
786
+ if ((typescript_1.default.isArrowFunction(init) || typescript_1.default.isFunctionExpression(init)) &&
787
+ init.body) {
788
+ funcBodies.set(decl.name.text, init.body);
789
+ if (isExported) {
790
+ exportedNames.add(decl.name.text);
791
+ }
792
+ }
793
+ }
794
+ }
795
+ // export { name1, name2 }
796
+ if (typescript_1.default.isExportDeclaration(stmt) &&
797
+ stmt.exportClause &&
798
+ typescript_1.default.isNamedExports(stmt.exportClause)) {
799
+ for (const el of stmt.exportClause.elements) {
800
+ const localName = el.propertyName ? el.propertyName.text : el.name.text;
801
+ exportedNames.add(localName);
802
+ }
803
+ }
804
+ // export default identifier
805
+ if (typescript_1.default.isExportAssignment(stmt) && typescript_1.default.isIdentifier(stmt.expression)) {
806
+ exportedNames.add(stmt.expression.text);
807
+ }
808
+ }
809
+ // 3. For each exported function, check if body is a sleep wrapper
810
+ const results = [];
811
+ for (const name of exportedNames) {
812
+ const body = funcBodies.get(name);
813
+ if (!body)
814
+ continue;
815
+ if (bodyHasRetryLoop(body, sf))
816
+ continue;
817
+ if (bodyContainsSleepPattern(body)) {
818
+ results.push(name);
819
+ }
820
+ }
821
+ return results;
822
+ }
823
+ /**
824
+ * Parse a utility module and return exported classes that contain methods
825
+ * whose body is a sleep wrapper (Promise-setTimeout, waitForTimeout, direct setTimeout).
826
+ * Excludes methods with retry/polling loop patterns (false-positive filter).
827
+ */
828
+ function findClassSleepMethodExports(filePath, content) {
829
+ const sf = parseSource(filePath, content);
830
+ // Collect names exported via `export { ClassName }` or `export default ident`
831
+ const reExportedNames = new Set();
832
+ const defaultExportedNames = new Set();
833
+ for (const stmt of sf.statements) {
834
+ if (typescript_1.default.isExportDeclaration(stmt) &&
835
+ stmt.exportClause &&
836
+ typescript_1.default.isNamedExports(stmt.exportClause)) {
837
+ for (const el of stmt.exportClause.elements) {
838
+ const localName = el.propertyName ? el.propertyName.text : el.name.text;
839
+ reExportedNames.add(localName);
840
+ }
841
+ }
842
+ if (typescript_1.default.isExportAssignment(stmt) && typescript_1.default.isIdentifier(stmt.expression)) {
843
+ defaultExportedNames.add(stmt.expression.text);
844
+ }
845
+ }
846
+ const results = [];
847
+ for (const stmt of sf.statements) {
848
+ if (!typescript_1.default.isClassDeclaration(stmt))
849
+ continue;
850
+ // Determine if the class is exported
851
+ const hasExportModifier = stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.ExportKeyword);
852
+ const className = stmt.name?.text;
853
+ const isExported = hasExportModifier ||
854
+ (className !== undefined && reExportedNames.has(className)) ||
855
+ (className !== undefined && defaultExportedNames.has(className));
856
+ if (!isExported)
857
+ continue;
858
+ const sleepMethods = [];
859
+ for (const member of stmt.members) {
860
+ if (!typescript_1.default.isMethodDeclaration(member))
861
+ continue;
862
+ if (!member.body)
863
+ continue;
864
+ if (!member.name || !typescript_1.default.isIdentifier(member.name))
865
+ continue;
866
+ if (bodyHasRetryLoop(member.body, sf))
867
+ continue;
868
+ if (bodyContainsSleepPattern(member.body)) {
869
+ sleepMethods.push(member.name.text);
870
+ }
871
+ }
872
+ if (sleepMethods.length > 0) {
873
+ results.push({
874
+ className: className ?? "default",
875
+ methodNames: sleepMethods,
876
+ });
877
+ }
878
+ }
879
+ return results;
880
+ }
881
+ /**
882
+ * Given a test file and a map of wrapper binding names → import paths,
883
+ * find call sites of those wrappers. Returns line numbers and function names.
884
+ */
885
+ function findDisguisedHardWaitCalls(filePath, content, wrapperBindings, sleepMethodBindings) {
886
+ if (wrapperBindings.size === 0 && (!sleepMethodBindings || sleepMethodBindings.size === 0))
887
+ return [];
888
+ const sf = parseSource(filePath, content);
889
+ const results = [];
890
+ function visit(node) {
891
+ if (typescript_1.default.isCallExpression(node)) {
892
+ // Direct function call: delay(2000)
893
+ if (typescript_1.default.isIdentifier(node.expression)) {
894
+ const name = node.expression.text;
895
+ const importPath = wrapperBindings.get(name);
896
+ if (importPath) {
897
+ results.push({
898
+ file: filePath,
899
+ line: lineOf(sf, node.getStart()),
900
+ calledFunction: name,
901
+ importPath,
902
+ });
903
+ }
904
+ }
905
+ // Property access call: instance.delay(2000) or this.delay(2000)
906
+ if (sleepMethodBindings &&
907
+ sleepMethodBindings.size > 0 &&
908
+ typescript_1.default.isPropertyAccessExpression(node.expression)) {
909
+ const methodName = node.expression.name.text;
910
+ const importPath = sleepMethodBindings.get(methodName);
911
+ if (importPath) {
912
+ results.push({
913
+ file: filePath,
914
+ line: lineOf(sf, node.getStart()),
915
+ calledFunction: methodName,
916
+ importPath,
917
+ });
918
+ }
919
+ }
920
+ }
921
+ typescript_1.default.forEachChild(node, visit);
922
+ }
923
+ visit(sf);
924
+ return results;
925
+ }
926
+ // ─── PW-FLAKE-010: Retrieval Methods Without Content Guard ────────────
927
+ const RETRIEVAL_METHODS = new Set([
928
+ "innerText", "textContent", "allInnerTexts",
929
+ "allTextContents", "innerHTML", "inputValue",
930
+ ]);
931
+ const CONTENT_GUARD_MATCHERS = new Set([
932
+ "toHaveText", "toContainText", "toBeEmpty", "toBeVisible",
933
+ ]);
934
+ /**
935
+ * Check whether a CallExpression is a content-guard assertion.
936
+ *
937
+ * Content guards are:
938
+ * 1. `expect()` chains whose final matcher is one of `toHaveText`,
939
+ * `toContainText`, `toBeEmpty`, `toBeVisible`, or `toPass` — including
940
+ * negated forms (`.not.toHaveText(...)`) and `.resolves`/`.rejects`.
941
+ * 2. Direct `locator.waitFor()` calls — these wait for the element to reach
942
+ * an actionable state (default: "visible"), ensuring content is ready.
943
+ */
944
+ function isContentGuard(node) {
945
+ if (!typescript_1.default.isCallExpression(node))
946
+ return false;
947
+ const methodName = getFinalMethodName(node);
948
+ if (!methodName)
949
+ return false;
950
+ // locator.waitFor() is a guard (waits for "visible" state by default)
951
+ if (methodName === "waitFor") {
952
+ // Accept if it's NOT inside an expect() chain — i.e. it's a direct locator call
953
+ const kind = getExpectChainKind(node);
954
+ if (kind === undefined)
955
+ return true;
956
+ }
957
+ // toPass() is a guard regardless of chain root
958
+ if (methodName === "toPass") {
959
+ const kind = getExpectChainKind(node);
960
+ return kind !== undefined;
961
+ }
962
+ if (!CONTENT_GUARD_MATCHERS.has(methodName))
963
+ return false;
964
+ const kind = getExpectChainKind(node);
965
+ return kind !== undefined;
966
+ }
967
+ /**
968
+ * Check if a CallExpression is a retrieval method call: `.innerText()`, etc.
969
+ * Returns the method name if matched, undefined otherwise.
970
+ */
971
+ function getRetrievalMethodName(node) {
972
+ if (!typescript_1.default.isPropertyAccessExpression(node.expression))
973
+ return undefined;
974
+ const name = node.expression.name.text;
975
+ return RETRIEVAL_METHODS.has(name) ? name : undefined;
976
+ }
977
+ /**
978
+ * Detect retrieval method calls (innerText, textContent, innerHTML, etc.)
979
+ * that are NOT preceded by a content guard within the same test body.
980
+ *
981
+ * A content guard is an expect() assertion with toHaveText, toContainText,
982
+ * toBeEmpty, or toPass that targets the same locator (or any locator within
983
+ * the preceding statements). The AST approach lets us check the actual
984
+ * statement ordering within the test callback body rather than relying on
985
+ * a fragile line-window regex heuristic.
986
+ */
987
+ function findUnguardedRetrievalMethods(filePath, content) {
988
+ const sf = parseSource(filePath, content);
989
+ const results = [];
990
+ function visit(node) {
991
+ if (!typescript_1.default.isCallExpression(node)) {
992
+ typescript_1.default.forEachChild(node, visit);
993
+ return;
994
+ }
995
+ // Match test(...), test.only(...), test.slow(...)
996
+ const isTestCall = isCallTo(node, "test") || isMethodCall(node, "test", "only") ||
997
+ isMethodCall(node, "test", "slow");
998
+ if (!isTestCall) {
999
+ typescript_1.default.forEachChild(node, visit);
1000
+ return;
1001
+ }
1002
+ // Skip hooks and describe
1003
+ if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
1004
+ const method = node.expression.name.text;
1005
+ if (method === "beforeAll" || method === "afterAll" ||
1006
+ method === "beforeEach" || method === "afterEach" ||
1007
+ method === "describe") {
1008
+ typescript_1.default.forEachChild(node, visit);
1009
+ return;
1010
+ }
1011
+ }
1012
+ // Extract test name
1013
+ let testName = "(unnamed)";
1014
+ if (node.arguments.length > 0 && typescript_1.default.isStringLiteral(node.arguments[0])) {
1015
+ testName = node.arguments[0].text;
1016
+ }
1017
+ // Find the callback
1018
+ let callback;
1019
+ for (const arg of node.arguments) {
1020
+ if (typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) {
1021
+ callback = arg;
1022
+ }
1023
+ }
1024
+ if (!callback || !callback.body || !typescript_1.default.isBlock(callback.body)) {
1025
+ typescript_1.default.forEachChild(node, visit);
1026
+ return;
1027
+ }
1028
+ // Collect statements in order; track which statements contain content guards
1029
+ const stmts = callback.body.statements;
1030
+ const guardSeen = new Set(); // statement indices that contain guards
1031
+ // First pass: identify which statements contain content guards
1032
+ for (let i = 0; i < stmts.length; i++) {
1033
+ if (stmtContainsContentGuard(stmts[i])) {
1034
+ guardSeen.add(i);
1035
+ }
1036
+ }
1037
+ // Second pass: find retrieval method calls and check for preceding guards
1038
+ for (let i = 0; i < stmts.length; i++) {
1039
+ collectRetrievalCalls(stmts[i], sf).forEach((call) => {
1040
+ // Check if any of the preceding statements (within 5 statements) is a guard
1041
+ const windowStart = Math.max(0, i - 5);
1042
+ let guarded = false;
1043
+ for (let j = windowStart; j < i; j++) {
1044
+ if (guardSeen.has(j)) {
1045
+ guarded = true;
1046
+ break;
1047
+ }
1048
+ }
1049
+ // If the statement itself contains a guard, treat the retrieval as guarded
1050
+ if (guardSeen.has(i))
1051
+ guarded = true;
1052
+ if (!guarded) {
1053
+ results.push({
1054
+ file: filePath,
1055
+ line: call.line,
1056
+ testName,
1057
+ snippet: `.${call.method}()`,
1058
+ });
1059
+ }
1060
+ });
1061
+ }
1062
+ }
1063
+ visit(sf);
1064
+ return results;
1065
+ }
1066
+ /**
1067
+ * Scan non-test files (POMs, page classes, fixtures) for retrieval method
1068
+ * calls without a preceding content guard in the same function body.
1069
+ *
1070
+ * Walks every function, method, and arrow-function body in the file.
1071
+ * Uses the same 5-statement guard window as the test-scoped variant.
1072
+ */
1073
+ function findUnguardedRetrievalMethodsInFunctions(filePath, content) {
1074
+ const sf = parseSource(filePath, content);
1075
+ const results = [];
1076
+ function visit(node) {
1077
+ // Skip arrow/function expressions that are arguments to expect() chains —
1078
+ // these are assertion/polling callbacks where retrieval is intentional.
1079
+ if ((typescript_1.default.isArrowFunction(node) || typescript_1.default.isFunctionExpression(node)) &&
1080
+ typescript_1.default.isCallExpression(node.parent)) {
1081
+ const parentCall = node.parent;
1082
+ // Direct expect(..., callback) or expect.poll(callback)
1083
+ const chainKind = getExpectChainKind(parentCall);
1084
+ if (chainKind !== undefined)
1085
+ return;
1086
+ // Callback is an argument to expect() itself: expect(async () => { ... })
1087
+ if (typescript_1.default.isIdentifier(parentCall.expression) &&
1088
+ parentCall.expression.text === "expect")
1089
+ return;
1090
+ }
1091
+ // Match any function-like body: methods, arrow functions, function declarations/expressions
1092
+ let body;
1093
+ let scopeName = "(anonymous)";
1094
+ if (typescript_1.default.isFunctionDeclaration(node) || typescript_1.default.isFunctionExpression(node)) {
1095
+ body = node.body && typescript_1.default.isBlock(node.body) ? node.body : undefined;
1096
+ scopeName = node.name?.text ?? "(anonymous)";
1097
+ }
1098
+ else if (typescript_1.default.isMethodDeclaration(node)) {
1099
+ body = node.body && typescript_1.default.isBlock(node.body) ? node.body : undefined;
1100
+ if (typescript_1.default.isIdentifier(node.name))
1101
+ scopeName = node.name.text;
1102
+ else if (typescript_1.default.isStringLiteral(node.name))
1103
+ scopeName = node.name.text;
1104
+ }
1105
+ else if (typescript_1.default.isArrowFunction(node) && node.body && typescript_1.default.isBlock(node.body)) {
1106
+ body = node.body;
1107
+ // Try to get name from parent variable declaration
1108
+ if (typescript_1.default.isVariableDeclaration(node.parent) && typescript_1.default.isIdentifier(node.parent.name)) {
1109
+ scopeName = node.parent.name.text;
1110
+ }
1111
+ }
1112
+ if (body) {
1113
+ const stmts = body.statements;
1114
+ const guardSeen = new Set();
1115
+ for (let i = 0; i < stmts.length; i++) {
1116
+ if (stmtContainsContentGuard(stmts[i])) {
1117
+ guardSeen.add(i);
1118
+ }
1119
+ }
1120
+ for (let i = 0; i < stmts.length; i++) {
1121
+ collectRetrievalCalls(stmts[i], sf).forEach((call) => {
1122
+ const windowStart = Math.max(0, i - 5);
1123
+ let guarded = false;
1124
+ for (let j = windowStart; j < i; j++) {
1125
+ if (guardSeen.has(j)) {
1126
+ guarded = true;
1127
+ break;
1128
+ }
1129
+ }
1130
+ if (guardSeen.has(i))
1131
+ guarded = true;
1132
+ if (!guarded) {
1133
+ results.push({
1134
+ file: filePath,
1135
+ line: call.line,
1136
+ testName: scopeName,
1137
+ snippet: `.${call.method}()`,
1138
+ });
1139
+ }
1140
+ });
1141
+ }
1142
+ }
1143
+ typescript_1.default.forEachChild(node, visit);
1144
+ }
1145
+ visit(sf);
1146
+ return results;
1147
+ }
1148
+ /** Check if any node within a statement subtree contains a content guard. */
1149
+ function stmtContainsContentGuard(node) {
1150
+ if (isContentGuard(node))
1151
+ return true;
1152
+ let found = false;
1153
+ function walk(n) {
1154
+ if (found)
1155
+ return;
1156
+ if (isContentGuard(n)) {
1157
+ found = true;
1158
+ return;
1159
+ }
1160
+ typescript_1.default.forEachChild(n, walk);
1161
+ }
1162
+ typescript_1.default.forEachChild(node, walk);
1163
+ return found;
1164
+ }
1165
+ /** Collect all retrieval method calls from a statement subtree.
1166
+ * Skips nested arrow/function expressions (callbacks) since those
1167
+ * run in a different execution context (e.g., expect.poll callbacks). */
1168
+ function collectRetrievalCalls(node, sf) {
1169
+ const calls = [];
1170
+ function walk(n) {
1171
+ // Don't descend into nested callbacks — those are separate contexts
1172
+ if (typescript_1.default.isArrowFunction(n) || typescript_1.default.isFunctionExpression(n))
1173
+ return;
1174
+ if (typescript_1.default.isCallExpression(n)) {
1175
+ const method = getRetrievalMethodName(n);
1176
+ if (method) {
1177
+ calls.push({ line: lineOf(sf, n.getStart()), method });
1178
+ }
1179
+ }
1180
+ typescript_1.default.forEachChild(n, walk);
1181
+ }
1182
+ walk(node);
1183
+ return calls;
1184
+ }
1185
+ // ─── ARCH-002: AST-Based POM "Locator Bag" Detection ─────────────────
1186
+ /** Locator-related Playwright page method prefixes. */
1187
+ const LOCATOR_PAGE_METHODS = new Set([
1188
+ "locator",
1189
+ "getByRole",
1190
+ "getByText",
1191
+ "getByLabel",
1192
+ "getByPlaceholder",
1193
+ "getByAltText",
1194
+ "getByTitle",
1195
+ "getByTestId",
1196
+ ]);
1197
+ function isLocatorInit(expr) {
1198
+ // Matches: (this.)page.locator() / (this.)page.getBy*()
1199
+ if (!typescript_1.default.isCallExpression(expr))
1200
+ return false;
1201
+ const callee = expr.expression;
1202
+ if (!typescript_1.default.isPropertyAccessExpression(callee))
1203
+ return false;
1204
+ const methodName = callee.name.text;
1205
+ if (!LOCATOR_PAGE_METHODS.has(methodName))
1206
+ return false;
1207
+ // The object should be `page` or `this.page`
1208
+ const obj = callee.expression;
1209
+ if (typescript_1.default.isIdentifier(obj) && obj.text === "page")
1210
+ return true;
1211
+ if (typescript_1.default.isPropertyAccessExpression(obj) &&
1212
+ obj.name.text === "page" &&
1213
+ obj.expression.kind === typescript_1.default.SyntaxKind.ThisKeyword)
1214
+ return true;
1215
+ return false;
1216
+ }
1217
+ function hasModifier(node, kind) {
1218
+ const mods = typescript_1.default.canHaveModifiers(node) ? typescript_1.default.getModifiers(node) : undefined;
1219
+ return mods?.some((m) => m.kind === kind) ?? false;
1220
+ }
1221
+ function isPrivateOrProtected(node) {
1222
+ return (hasModifier(node, typescript_1.default.SyntaxKind.PrivateKeyword) ||
1223
+ hasModifier(node, typescript_1.default.SyntaxKind.ProtectedKeyword));
1224
+ }
1225
+ /**
1226
+ * Analyze a POM file's class structure to determine if it is a "locator bag":
1227
+ * a class with many public locators and few/no action methods that use them.
1228
+ *
1229
+ * Returns null if the file does not contain a class declaration.
1230
+ */
1231
+ function analyzePomClassStructure(filePath, content) {
1232
+ const sf = parseSource(filePath, content);
1233
+ // Find the first class declaration
1234
+ let classDecl;
1235
+ function findClass(node) {
1236
+ if (classDecl)
1237
+ return;
1238
+ if (typescript_1.default.isClassDeclaration(node)) {
1239
+ classDecl = node;
1240
+ return;
1241
+ }
1242
+ typescript_1.default.forEachChild(node, findClass);
1243
+ }
1244
+ findClass(sf);
1245
+ if (!classDecl)
1246
+ return null;
1247
+ const className = classDecl.name?.text ?? "(anonymous)";
1248
+ let extendsClass = null;
1249
+ if (classDecl.heritageClauses) {
1250
+ for (const clause of classDecl.heritageClauses) {
1251
+ if (clause.token === typescript_1.default.SyntaxKind.ExtendsKeyword && clause.types.length > 0) {
1252
+ const baseExpr = clause.types[0].expression;
1253
+ if (typescript_1.default.isIdentifier(baseExpr)) {
1254
+ extendsClass = baseExpr.text;
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ // Phase 1: Discover public locator properties
1260
+ const locatorNames = [];
1261
+ // Map property names to their declarations for modifier checking
1262
+ const propertyDecls = new Map();
1263
+ for (const member of classDecl.members) {
1264
+ if (typescript_1.default.isPropertyDeclaration(member) && typescript_1.default.isIdentifier(member.name)) {
1265
+ propertyDecls.set(member.name.text, member);
1266
+ }
1267
+ }
1268
+ // 1a: Property declarations with locator initializers
1269
+ for (const member of classDecl.members) {
1270
+ if (typescript_1.default.isPropertyDeclaration(member) && typescript_1.default.isIdentifier(member.name)) {
1271
+ if (isPrivateOrProtected(member))
1272
+ continue;
1273
+ if (member.initializer && isLocatorInit(member.initializer)) {
1274
+ locatorNames.push(member.name.text);
1275
+ }
1276
+ }
1277
+ }
1278
+ // 1b: Constructor-assigned locators: this.foo = (this.)page.locator/getBy*()
1279
+ for (const member of classDecl.members) {
1280
+ if (!typescript_1.default.isConstructorDeclaration(member) || !member.body)
1281
+ continue;
1282
+ for (const stmt of member.body.statements) {
1283
+ if (!typescript_1.default.isExpressionStatement(stmt))
1284
+ continue;
1285
+ const expr = stmt.expression;
1286
+ if (!typescript_1.default.isBinaryExpression(expr) || expr.operatorToken.kind !== typescript_1.default.SyntaxKind.EqualsToken)
1287
+ continue;
1288
+ // LHS: this.foo
1289
+ const lhs = expr.left;
1290
+ if (!typescript_1.default.isPropertyAccessExpression(lhs) ||
1291
+ lhs.expression.kind !== typescript_1.default.SyntaxKind.ThisKeyword ||
1292
+ !typescript_1.default.isIdentifier(lhs.name))
1293
+ continue;
1294
+ const propName = lhs.name.text;
1295
+ // Skip if already found via property declaration
1296
+ if (locatorNames.includes(propName))
1297
+ continue;
1298
+ // Check the RHS is a locator init
1299
+ if (!isLocatorInit(expr.right))
1300
+ continue;
1301
+ // Check if the property declaration has private/protected
1302
+ const propDecl = propertyDecls.get(propName);
1303
+ if (propDecl && isPrivateOrProtected(propDecl))
1304
+ continue;
1305
+ locatorNames.push(propName);
1306
+ }
1307
+ }
1308
+ const publicLocatorCount = locatorNames.length;
1309
+ const locatorNameSet = new Set(locatorNames);
1310
+ // Phase 2: Count action methods that reference locators via this.<locatorName>
1311
+ const actionMethodNames = [];
1312
+ let totalMethodCount = 0;
1313
+ for (const member of classDecl.members) {
1314
+ // Skip getters and setters — they're separate AST node types
1315
+ if (typescript_1.default.isGetAccessorDeclaration(member))
1316
+ continue;
1317
+ if (typescript_1.default.isSetAccessorDeclaration(member))
1318
+ continue;
1319
+ if (!typescript_1.default.isMethodDeclaration(member))
1320
+ continue;
1321
+ if (!typescript_1.default.isIdentifier(member.name))
1322
+ continue;
1323
+ // Skip constructor (already handled above; methods only)
1324
+ const methodName = member.name.text;
1325
+ if (methodName === "constructor")
1326
+ continue;
1327
+ totalMethodCount++;
1328
+ // Check if method body references this.<locatorName>
1329
+ if (!member.body)
1330
+ continue;
1331
+ let referencesLocator = false;
1332
+ function checkLocatorRef(node) {
1333
+ if (referencesLocator)
1334
+ return;
1335
+ if (typescript_1.default.isPropertyAccessExpression(node) &&
1336
+ node.expression.kind === typescript_1.default.SyntaxKind.ThisKeyword &&
1337
+ typescript_1.default.isIdentifier(node.name) &&
1338
+ locatorNameSet.has(node.name.text)) {
1339
+ referencesLocator = true;
1340
+ return;
1341
+ }
1342
+ typescript_1.default.forEachChild(node, checkLocatorRef);
1343
+ }
1344
+ checkLocatorRef(member.body);
1345
+ if (referencesLocator) {
1346
+ actionMethodNames.push(methodName);
1347
+ }
1348
+ }
1349
+ const actionMethodCount = actionMethodNames.length;
1350
+ // A POM is a "locator bag" when it has many public locators
1351
+ // and fewer than 30% of them have wrapping action methods.
1352
+ const isLocatorBag = publicLocatorCount > 5 &&
1353
+ (publicLocatorCount === 0 || actionMethodCount / publicLocatorCount < 0.3);
1354
+ return {
1355
+ className,
1356
+ extendsClass,
1357
+ publicLocatorCount,
1358
+ locatorNames,
1359
+ actionMethodCount,
1360
+ actionMethodNames,
1361
+ totalMethodCount,
1362
+ isLocatorBag,
1363
+ };
1364
+ }
1365
+ // ─── ARCH-008: Large Inline Data Literals ────────────────────────────
1366
+ /**
1367
+ * Check whether an ObjectLiteralExpression or ArrayLiteralExpression is
1368
+ * "data-like": mostly primitives / nested object-arrays, no function or
1369
+ * class values. Returns false if any element is an arrow function,
1370
+ * function expression, or class expression.
1371
+ */
1372
+ function isDataLike(node) {
1373
+ const children = typescript_1.default.isObjectLiteralExpression(node)
1374
+ ? node.properties
1375
+ : node.elements;
1376
+ for (const child of children) {
1377
+ if (typescript_1.default.isArrowFunction(child) || typescript_1.default.isFunctionExpression(child) || typescript_1.default.isClassExpression(child)) {
1378
+ return false;
1379
+ }
1380
+ if (typescript_1.default.isPropertyAssignment(child)) {
1381
+ const init = child.initializer;
1382
+ if (typescript_1.default.isArrowFunction(init) || typescript_1.default.isFunctionExpression(init) || typescript_1.default.isClassExpression(init)) {
1383
+ return false;
1384
+ }
1385
+ }
1386
+ if (typescript_1.default.isSpreadAssignment(child) || typescript_1.default.isSpreadElement(child)) {
1387
+ // Spread from an import (e.g. ...basePayload) is fine — skip deep check
1388
+ continue;
1389
+ }
1390
+ }
1391
+ return true;
1392
+ }
1393
+ /**
1394
+ * Infer a symbol name for the variable/property that holds the literal.
1395
+ * Walks up through parents: VariableDeclaration → name, PropertyAssignment → name,
1396
+ * ExportAssignment → "default", otherwise "(anonymous)".
1397
+ */
1398
+ function inferSymbolName(node) {
1399
+ const parent = node.parent;
1400
+ if (!parent)
1401
+ return "(anonymous)";
1402
+ if (typescript_1.default.isVariableDeclaration(parent) && typescript_1.default.isIdentifier(parent.name)) {
1403
+ return parent.name.text;
1404
+ }
1405
+ if (typescript_1.default.isPropertyAssignment(parent) && typescript_1.default.isIdentifier(parent.name)) {
1406
+ return parent.name.text;
1407
+ }
1408
+ if (typescript_1.default.isPropertyAssignment(parent) && typescript_1.default.isStringLiteral(parent.name)) {
1409
+ return parent.name.text;
1410
+ }
1411
+ if (typescript_1.default.isExportAssignment(parent)) {
1412
+ return "default";
1413
+ }
1414
+ // `as const` satisfies: unwrap one level
1415
+ if (typescript_1.default.isAsExpression(parent) || typescript_1.default.isSatisfiesExpression(parent)) {
1416
+ return inferSymbolName(parent);
1417
+ }
1418
+ return "(anonymous)";
1419
+ }
1420
+ /**
1421
+ * Find large inline object/array literals (≥ threshold lines) in a source file.
1422
+ * Only counts ObjectLiteralExpression and ArrayLiteralExpression nodes that are
1423
+ * data-like (no function values). Ignores function bodies, class bodies, type
1424
+ * declarations, and import/export blocks.
1425
+ */
1426
+ function findLargeInlineDataLiterals(filePath, content, threshold = 50) {
1427
+ const sf = parseSource(filePath, content);
1428
+ const results = [];
1429
+ function visit(node) {
1430
+ // Only inspect object and array literal expressions
1431
+ if ((typescript_1.default.isObjectLiteralExpression(node) || typescript_1.default.isArrayLiteralExpression(node)) &&
1432
+ isDataLike(node)) {
1433
+ const startLine = lineOf(sf, node.getStart());
1434
+ const endLine = lineOf(sf, node.getEnd());
1435
+ const lineCount = endLine - startLine + 1;
1436
+ if (lineCount >= threshold) {
1437
+ results.push({
1438
+ file: filePath,
1439
+ line: startLine,
1440
+ endLine,
1441
+ lineCount,
1442
+ symbolName: inferSymbolName(node),
1443
+ nodeType: typescript_1.default.isObjectLiteralExpression(node) ? "object" : "array",
1444
+ });
1445
+ }
1446
+ }
1447
+ typescript_1.default.forEachChild(node, visit);
1448
+ }
1449
+ visit(sf);
1450
+ return results;
1451
+ }