playwright-intelligence-engine 2.0.1 → 2.0.2

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.
Files changed (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. package/src/selector/selector-scorer.ts +344 -344
@@ -1,796 +1,796 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import crypto from 'node:crypto';
4
- import { Project, SyntaxKind } from 'ts-morph';
5
- import { inspectRepository, RepositoryManifest } from '../evaluation/repository-inspector.js';
6
-
7
- export const REPOSITORY_PROFILE_VERSION = 1;
8
-
9
- export interface IdentifierConventions {
10
- propertySuffixes: string[];
11
- methodVerbs: Record<string, number>;
12
- usesReadonly: boolean;
13
- pageInjection: 'constructor-param-property' | 'constructor-assignment' | 'getter' | 'inline';
14
- gotoMethodName: string;
15
- recommendedLocatorPolicy: 'prefer-role-over-css' | 'prefer-testid' | 'prefer-label' | 'prefer-locator' | 'unknown';
16
- }
17
-
18
- export interface PageObjectExemplar {
19
- filePath: string;
20
- className: string;
21
- content: string;
22
- }
23
-
24
- export interface RepositoryConventionProfile {
25
- version: typeof REPOSITORY_PROFILE_VERSION;
26
- generatedAt: string;
27
- /** Fingerprint of source paths and metadata used to detect convention drift. */
28
- sourceFingerprint?: string | undefined;
29
- repository: {
30
- name: string;
31
- root: string;
32
- packageManager: RepositoryManifest['packageManager'];
33
- playwrightVersion?: string | undefined;
34
- scripts: Record<string, string>;
35
- };
36
- structure: {
37
- testFiles: number;
38
- /** Test files whose source explicitly references Playwright. */
39
- playwrightTestFiles?: number | undefined;
40
- pageObjectFiles: number;
41
- fixtureFiles: number;
42
- configFiles: string[];
43
- usesPageObjects: boolean;
44
- usesCustomFixtures: boolean;
45
- usesApiHelpers: boolean;
46
- usesStorageState: boolean;
47
- };
48
- paths?: {
49
- testDirectory?: string | undefined;
50
- pageObjectDirectory?: string | undefined;
51
- fixtureDirectory?: string | undefined;
52
- };
53
- pathConfidence?: {
54
- testDirectory: number;
55
- pageObjectDirectory: number;
56
- fixtureDirectory: number;
57
- } | undefined;
58
- locatorConventions: {
59
- getByRole: number;
60
- getByTestId: number;
61
- getByLabel: number;
62
- getByText: number;
63
- locator: number;
64
- cssOrXpath: number;
65
- preferred: 'getByRole' | 'getByTestId' | 'getByLabel' | 'getByText' | 'locator' | 'unknown';
66
- };
67
- identifierConventions?: IdentifierConventions | undefined;
68
- exemplars?: PageObjectExemplar[] | undefined;
69
- testConventions: {
70
- tags: string[];
71
- usesDescribeBlocks: boolean;
72
- usesTestSteps: boolean;
73
- usesAssertions: boolean;
74
- baseUrl?: string | undefined;
75
- };
76
- domainTerms: string[];
77
- evidence: string[];
78
- }
79
-
80
- async function listSourceFiles(root: string, depth = 0): Promise<string[]> {
81
- if (depth > 6) return [];
82
- const ignored = new Set([
83
- 'node_modules', '.git', 'dist', 'build', 'coverage', 'test-results',
84
- 'playwright-report', 'reports', '.next', '.turbo', '.pie', '.pw-intelligence',
85
- 'repositories', 'pie-evaluation', 'generation', 'repair-worktrees',
86
- 'temp', 'tmp'
87
- ]);
88
- let entries;
89
- try {
90
- entries = await fs.readdir(root, { withFileTypes: true });
91
- } catch {
92
- return [];
93
- }
94
- const files: string[] = [];
95
- for (const entry of entries) {
96
- if (ignored.has(entry.name)) continue;
97
- const fullPath = path.join(root, entry.name);
98
- if (entry.isDirectory()) files.push(...await listSourceFiles(fullPath, depth + 1));
99
- else if (/\.(?:ts|tsx|js|jsx|mjs|cjs)$/i.test(entry.name) && !/\.d\.ts$/i.test(entry.name)) files.push(fullPath);
100
- }
101
- return files;
102
- }
103
-
104
- /** Returns whether a directory has enough project markers to profile safely. */
105
- export async function isLikelyProjectRoot(root: string): Promise<boolean> {
106
- const projectRoot = path.resolve(root);
107
- const markers = ['package.json', 'playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs', 'playwright.config.cjs'];
108
- for (const marker of markers) {
109
- try {
110
- await fs.access(path.join(projectRoot, marker));
111
- return true;
112
- } catch {
113
- // Try the next project marker.
114
- }
115
- }
116
- return false;
117
- }
118
-
119
- /** Detects evaluation/workspace containers that must not be profiled recursively. */
120
- export async function isLikelyRepositoryContainer(root: string): Promise<boolean> {
121
- const containerNames = new Set(['repositories', 'pie-evaluation', 'generation', 'repair-worktrees', 'generated-test-data']);
122
- try {
123
- const entries = await fs.readdir(path.resolve(root), { withFileTypes: true });
124
- return entries.some((entry) => entry.isDirectory() && containerNames.has(entry.name));
125
- } catch {
126
- return false;
127
- }
128
- }
129
-
130
- function countMatches(contents: string, pattern: RegExp): number {
131
- return contents.match(pattern)?.length ?? 0;
132
- }
133
-
134
- function preferredLocator(counts: Omit<RepositoryConventionProfile['locatorConventions'], 'preferred'>): RepositoryConventionProfile['locatorConventions']['preferred'] {
135
- // Policy order: role > label > testid > text > locator
136
- if (counts.getByRole > 0) return 'getByRole';
137
- if (counts.getByLabel > 0) return 'getByLabel';
138
- if (counts.getByTestId > 0) return 'getByTestId';
139
- if (counts.getByText > 0) return 'getByText';
140
- if (counts.locator > 0) return 'locator';
141
- return 'unknown';
142
- }
143
-
144
- async function sampleIdentifierConventions(
145
- pomFiles: string[],
146
- locatorCounts: Omit<RepositoryConventionProfile['locatorConventions'], 'preferred'>
147
- ): Promise<IdentifierConventions> {
148
- const propertySuffixesCount = new Map<string, number>();
149
- const methodVerbs: Record<string, number> = {};
150
- let readonlyCount = 0;
151
- let totalProperties = 0;
152
- let pageInjection: IdentifierConventions['pageInjection'] = 'constructor-assignment';
153
- let gotoMethodName = 'goto';
154
-
155
- const knownSuffixes = ['Button', 'Input', 'Link', 'Select', 'Checkbox', 'Dialog', 'Modal', 'Heading', 'Cell', 'Table', 'Tab', 'Radio', 'Status', 'Card'];
156
- const sampleFiles = pomFiles.slice(0, 5);
157
-
158
- if (sampleFiles.length > 0) {
159
- try {
160
- const project = new Project({ useInMemoryFileSystem: true });
161
- for (const filePath of sampleFiles) {
162
- try {
163
- const content = await fs.readFile(filePath, 'utf8');
164
- const sourceFile = project.createSourceFile(path.basename(filePath), content, { overwrite: true });
165
-
166
- for (const cls of sourceFile.getClasses()) {
167
- const ctor = cls.getConstructors()[0];
168
- if (ctor) {
169
- const params = ctor.getParameters();
170
- if (params.some((p) => p.getScope() || p.isReadonly())) {
171
- pageInjection = 'constructor-param-property';
172
- }
173
- }
174
-
175
- for (const prop of cls.getProperties()) {
176
- totalProperties++;
177
- if (prop.isReadonly()) readonlyCount++;
178
- const name = prop.getName();
179
- for (const suffix of knownSuffixes) {
180
- if (name.endsWith(suffix)) {
181
- propertySuffixesCount.set(suffix, (propertySuffixesCount.get(suffix) ?? 0) + 1);
182
- }
183
- }
184
- }
185
-
186
- for (const getter of cls.getGetAccessors()) {
187
- const name = getter.getName();
188
- for (const suffix of knownSuffixes) {
189
- if (name.endsWith(suffix)) {
190
- propertySuffixesCount.set(suffix, (propertySuffixesCount.get(suffix) ?? 0) + 1);
191
- }
192
- }
193
- }
194
-
195
- for (const method of cls.getMethods()) {
196
- const name = method.getName();
197
- if (name === 'goto' || name === 'open' || name === 'navigate' || name === 'load') {
198
- gotoMethodName = name;
199
- }
200
- const match = name.match(/^([a-z]+)/);
201
- if (match) {
202
- const verb = match[1]!;
203
- methodVerbs[verb] = (methodVerbs[verb] ?? 0) + 1;
204
- }
205
- }
206
- }
207
- } catch {
208
- // Continue to next sample file
209
- }
210
- }
211
- } catch {
212
- // Fallback
213
- }
214
- }
215
-
216
- let recommendedLocatorPolicy: IdentifierConventions['recommendedLocatorPolicy'] = 'unknown';
217
- if (locatorCounts.getByRole > 0) recommendedLocatorPolicy = 'prefer-role-over-css';
218
- else if (locatorCounts.getByLabel > 0) recommendedLocatorPolicy = 'prefer-label';
219
- else if (locatorCounts.getByTestId > 0) recommendedLocatorPolicy = 'prefer-testid';
220
- else if (locatorCounts.locator > 0) recommendedLocatorPolicy = 'prefer-locator';
221
-
222
- const propertySuffixes = [...propertySuffixesCount.entries()]
223
- .sort((a, b) => b[1] - a[1])
224
- .map(([s]) => s);
225
- if (propertySuffixes.length === 0) {
226
- propertySuffixes.push('Button', 'Input');
227
- }
228
-
229
- return {
230
- propertySuffixes,
231
- methodVerbs,
232
- usesReadonly: totalProperties > 0 ? readonlyCount / totalProperties >= 0.5 : true,
233
- pageInjection,
234
- gotoMethodName,
235
- recommendedLocatorPolicy,
236
- };
237
- }
238
-
239
- export async function sampleExemplars(pomFiles: string[], maxExemplars = 3): Promise<PageObjectExemplar[]> {
240
- const exemplars: PageObjectExemplar[] = [];
241
- for (const file of pomFiles.slice(0, maxExemplars)) {
242
- try {
243
- const content = await fs.readFile(file, 'utf8');
244
- const lines = content.split(/\r?\n/).slice(0, 120);
245
- const match = content.match(/export\s+class\s+([A-Za-z0-9_]+)/);
246
- const className = match ? match[1]! : path.basename(file, path.extname(file));
247
- exemplars.push({
248
- filePath: file,
249
- className,
250
- content: lines.join('\n'),
251
- });
252
- } catch {}
253
- }
254
- return exemplars;
255
- }
256
-
257
- function extractDomainTerms(filePaths: string[], contents: string): string[] {
258
- const ignored = new Set([
259
- 'test', 'tests', 'spec', 'specs', 'page', 'pages', 'pom', 'fixture', 'fixtures',
260
- 'should', 'be', 'able', 'to', 'the', 'and', 'with', 'from', 'for', 'using', 'login', 'user',
261
- 'expect', 'locator', 'bevisible', 'byrole', 'bytext', 'bytestid', 'bylabel', 'haveurl',
262
- 'eventlistener', 'containtext', 'havetext', 'totruthy', 'toequal', 'waitfor',
263
- 'click', 'fill', 'press', 'check', 'uncheck', 'select', 'focus', 'hover',
264
- 'promise', 'async', 'await', 'void', 'string', 'boolean', 'number', 'function',
265
- 'object', 'array', 'import', 'export', 'default', 'class', 'const', 'return',
266
- ]);
267
- const terms = new Map<string, number>();
268
- const candidates = [
269
- ...filePaths.flatMap((file) => path.basename(file).replace(/\.(?:spec|test)\.[^.]+$/i, '').split(/[^A-Za-z0-9]+/)),
270
- ...contents.match(/[A-Z][A-Za-z0-9]{3,}/g) ?? [],
271
- ];
272
- for (const candidate of candidates) {
273
- const term = candidate.toLowerCase();
274
- if (!ignored.has(term) && /^[a-z][a-z0-9-]{3,}$/.test(term)) terms.set(term, (terms.get(term) ?? 0) + 1);
275
- }
276
- return [...terms.entries()].sort((left, right) => right[1] - left[1]).slice(0, 30).map(([term]) => term);
277
- }
278
-
279
- export async function extractBaseUrlFromConfigs(configPaths: string[], root: string): Promise<string | undefined> {
280
- for (const configPath of configPaths) {
281
- try {
282
- const fullPath = path.isAbsolute(configPath) ? configPath : path.join(root, configPath);
283
- const content = await fs.readFile(fullPath, 'utf8');
284
-
285
- // 1. Direct baseURL: '...' or baseURL: "..." or baseURL: `...`
286
- const directMatch = content.match(/baseURL:\s*(?:process\.env\.[A-Za-z0-9_]+\s*\|\|\s*)?['"`](https?:\/\/[^'"`]+)['"`]/);
287
- if (directMatch?.[1]) {
288
- return directMatch[1];
289
- }
290
-
291
- // 2. Identifier baseURL: baseURL: <identifier>
292
- const identMatch = content.match(/baseURL:\s*([A-Za-z0-9_]+)/);
293
- if (identMatch?.[1] && identMatch[1] !== 'process') {
294
- const ident = identMatch[1];
295
- const declRegex = new RegExp(`(?:const|let|var)\\s+${ident}\\s*=\\s*(?:process\\.env\\.[A-Za-z0-9_]+\\s*\\|\\|\\s*)?['"\`](https?:\\/\\/[^'"\`]+)['"\`]`);
296
- const declMatch = content.match(declRegex);
297
- if (declMatch?.[1]) {
298
- return declMatch[1];
299
- }
300
- }
301
-
302
- // 3. Fallback: match any baseURL: '...' even if relative or local
303
- const anyMatch = content.match(/baseURL:\s*['"`]([^'"`]+)['"`]/);
304
- if (anyMatch?.[1]) {
305
- return anyMatch[1];
306
- }
307
- } catch {
308
- // Continue to next candidate
309
- }
310
- }
311
- return undefined;
312
- }
313
-
314
- function mostCommonDirectory(filePaths: string[], root: string, fallback: string): string {
315
- const counts = new Map<string, number>();
316
- for (const filePath of filePaths) {
317
- const relativeDirectory = path.relative(root, path.dirname(filePath)).replace(/\\/g, '/');
318
- const directory = relativeDirectory && relativeDirectory !== '.' ? relativeDirectory : fallback;
319
- counts.set(directory, (counts.get(directory) ?? 0) + 1);
320
- }
321
- const winner = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
322
- return winner?.[0] ?? fallback;
323
- }
324
-
325
- function directoryConfidence(filePaths: string[], root: string): number {
326
- if (filePaths.length === 0) return 0;
327
- const counts = new Map<string, number>();
328
- for (const filePath of filePaths) {
329
- const relativeDirectory = path.relative(root, path.dirname(filePath)).replace(/\\/g, '/');
330
- counts.set(relativeDirectory, (counts.get(relativeDirectory) ?? 0) + 1);
331
- }
332
- const strongest = Math.max(...counts.values());
333
- return Number((strongest / filePaths.length).toFixed(3));
334
- }
335
-
336
- function isPlaywrightTestSource(filePath: string, content: string): boolean {
337
- return /\.(?:spec|test)\.[^.]+$/i.test(filePath) &&
338
- /(?:@playwright\/test|['"]playwright(?:\/test)?['"]|\b(?:test|expect)\s*\.)/.test(content);
339
- }
340
-
341
- /** Computes a cheap change detector without rereading source contents. */
342
- export async function computeRepositoryProfileFingerprint(root: string): Promise<string> {
343
- const projectRoot = path.resolve(root);
344
- const sourceFiles = await listSourceFiles(projectRoot);
345
- const hash = crypto.createHash('sha256');
346
- for (const filePath of sourceFiles.sort()) {
347
- try {
348
- const stat = await fs.stat(filePath);
349
- hash.update(path.relative(projectRoot, filePath).replace(/\\/g, '/'));
350
- hash.update(`\0${stat.size}:${stat.mtimeMs}\0`);
351
- } catch {
352
- hash.update(`${path.relative(projectRoot, filePath).replace(/\\/g, '/')}\0missing\0`);
353
- }
354
- }
355
- return hash.digest('hex');
356
- }
357
-
358
- export async function buildRepositoryProfile(root: string, now = new Date()): Promise<RepositoryConventionProfile> {
359
- const manifest = await inspectRepository(root);
360
- const sourceFiles = await listSourceFiles(path.resolve(root));
361
- const sourceFingerprint = await computeRepositoryProfileFingerprint(root);
362
- const sourceRecords = await Promise.all(sourceFiles.map(async (file) => {
363
- try {
364
- return [file, await fs.readFile(file, 'utf8')] as const;
365
- } catch {
366
- return [file, ''] as const;
367
- }
368
- }));
369
- const sourceContents = sourceRecords.map(([, content]) => content).join('\n');
370
- const locatorCounts = {
371
- getByRole: countMatches(sourceContents, /\bgetByRole\s*\(/g),
372
- getByTestId: countMatches(sourceContents, /\bgetByTestId\s*\(/g),
373
- getByLabel: countMatches(sourceContents, /\bgetByLabel\s*\(/g),
374
- getByText: countMatches(sourceContents, /\bgetByText\s*\(/g),
375
- locator: countMatches(sourceContents, /\.(?:locator|frameLocator)\s*\(/g),
376
- cssOrXpath: countMatches(sourceContents, /(?:xpath=|css=|page\.locator\s*\(\s*['"`][#.\[])/g),
377
- };
378
- const testFiles = sourceFiles.filter((file) => /\.(?:spec|test)\.[^.]+$/i.test(file));
379
- const playwrightTestFiles = sourceRecords
380
- .filter(([file, content]) => isPlaywrightTestSource(file, content))
381
- .map(([file]) => file);
382
- const profileTestFiles = playwrightTestFiles.length > 0
383
- ? playwrightTestFiles
384
- : testFiles;
385
- const pageObjectFiles = sourceFiles.filter((file) => /(?:page|page-object)\.[^.]+$/i.test(file) || /pages?[\\/]/i.test(file));
386
- const fixtureFiles = sourceFiles.filter((file) => /fixture\.[^.]+$/i.test(file) || /fixtures?[\\/]/i.test(file));
387
-
388
- // Restrict tag and domain mining exclusively to source files containing /\btest(?:\.describe)?\s*\(/
389
- const testSourceRecords = sourceRecords.filter(([file, content]) =>
390
- /\.(?:spec|test)\.[^.]+$/i.test(file) &&
391
- !/\.d\.ts$/i.test(file) &&
392
- !/\.css$/i.test(file) &&
393
- /\btest(?:\.describe)?\s*\(/.test(content)
394
- );
395
- const testSourceContents = testSourceRecords.map(([, content]) => content).join('\n');
396
- const testSourcePaths = testSourceRecords.map(([file]) => file);
397
-
398
- const JSDOC_TAGS = new Set([
399
- '@param', '@returns', '@return', '@type', '@typedef', '@template',
400
- '@override', '@deprecated', '@see', '@example', '@throws',
401
- '@ts-expect-error', '@ts-ignore', '@ts-nocheck', '@ts-check',
402
- '@jest-environment', '@vitest-environment'
403
- ]);
404
- const tags = [...new Set([...testSourceContents.matchAll(/(@[A-Za-z][\w-]*)/g)]
405
- .map((match) => match[1]!)
406
- .filter((tag) => !JSDOC_TAGS.has(tag.toLowerCase()))
407
- )].sort();
408
-
409
- const baseUrl = await extractBaseUrlFromConfigs(manifest.playwrightConfigs, path.resolve(root));
410
-
411
- const evidence: string[] = [];
412
- if (manifest.pageObjectFiles > 0) evidence.push('Page Object files detected');
413
- if (manifest.fixtureFiles > 0) evidence.push('Fixture files detected');
414
- if (/\b(?:request|apiRequestContext|APIRequestContext)\b/i.test(sourceContents)) evidence.push('API/request helpers detected');
415
- if (/storageState|storage-state/i.test(sourceContents)) evidence.push('Playwright storage state detected');
416
- if (manifest.playwrightConfigs.length > 0) evidence.push('Playwright configuration detected');
417
- if (baseUrl) evidence.push(`Playwright baseURL discovered: ${baseUrl}`);
418
- if (playwrightTestFiles.length > 0) evidence.push(`${playwrightTestFiles.length} Playwright test source(s) detected`);
419
- else evidence.push('No Playwright test source detected; generic test files were not treated as Playwright conventions');
420
-
421
- return {
422
- version: REPOSITORY_PROFILE_VERSION,
423
- generatedAt: now.toISOString(),
424
- sourceFingerprint,
425
- repository: {
426
- name: manifest.name,
427
- root: manifest.root,
428
- packageManager: manifest.packageManager,
429
- playwrightVersion: manifest.playwrightVersion,
430
- scripts: manifest.scripts,
431
- },
432
- structure: {
433
- testFiles: manifest.testFiles,
434
- playwrightTestFiles: playwrightTestFiles.length,
435
- pageObjectFiles: manifest.pageObjectFiles,
436
- fixtureFiles: manifest.fixtureFiles,
437
- configFiles: manifest.playwrightConfigs,
438
- usesPageObjects: manifest.pageObjectFiles > 0,
439
- usesCustomFixtures: manifest.fixtureFiles > 0,
440
- usesApiHelpers: /\b(?:request|apiRequestContext|APIRequestContext)\b/i.test(sourceContents),
441
- usesStorageState: /storageState|storage-state/i.test(sourceContents),
442
- },
443
- paths: {
444
- testDirectory: mostCommonDirectory(profileTestFiles, path.resolve(root), 'tests'),
445
- pageObjectDirectory: mostCommonDirectory(pageObjectFiles, path.resolve(root), 'tests/pages'),
446
- fixtureDirectory: mostCommonDirectory(fixtureFiles, path.resolve(root), 'tests/fixtures'),
447
- },
448
- pathConfidence: {
449
- testDirectory: directoryConfidence(profileTestFiles, path.resolve(root)),
450
- pageObjectDirectory: directoryConfidence(pageObjectFiles, path.resolve(root)),
451
- fixtureDirectory: directoryConfidence(fixtureFiles, path.resolve(root)),
452
- },
453
- locatorConventions: { ...locatorCounts, preferred: preferredLocator(locatorCounts) },
454
- identifierConventions: await sampleIdentifierConventions(pageObjectFiles, locatorCounts),
455
- exemplars: await sampleExemplars(pageObjectFiles, 3),
456
- testConventions: {
457
- tags,
458
- usesDescribeBlocks: /\btest\.describe\s*\(/.test(testSourceContents || sourceContents),
459
- usesTestSteps: /\btest\.step\s*\(/.test(testSourceContents || sourceContents),
460
- usesAssertions: /\bexpect\s*\(/.test(testSourceContents || sourceContents),
461
- baseUrl,
462
- },
463
- domainTerms: extractDomainTerms(testSourcePaths, testSourceContents),
464
- evidence,
465
- };
466
- }
467
-
468
- export async function writeRepositoryProfile(
469
- root: string,
470
- profile?: RepositoryConventionProfile,
471
- ): Promise<{ profile: RepositoryConventionProfile; profilePath: string; conventionsPath: string }> {
472
- const projectRoot = path.resolve(root);
473
- const resolvedProfile = profile ?? await buildRepositoryProfile(projectRoot);
474
- const pieDir = path.join(projectRoot, '.pie');
475
- await fs.mkdir(pieDir, { recursive: true });
476
-
477
- const profilePath = path.join(pieDir, 'repository-profile.json');
478
- await fs.writeFile(profilePath, `${JSON.stringify(resolvedProfile, null, 2)}\n`, 'utf8');
479
-
480
- const conventionsPath = path.join(pieDir, 'conventions.json');
481
- const conventionsData = {
482
- version: 1,
483
- generatedAt: resolvedProfile.generatedAt,
484
- sourceFingerprint: resolvedProfile.sourceFingerprint,
485
- locatorConventions: resolvedProfile.locatorConventions,
486
- identifierConventions: resolvedProfile.identifierConventions,
487
- paths: resolvedProfile.paths,
488
- structure: resolvedProfile.structure,
489
- };
490
- await fs.writeFile(conventionsPath, `${JSON.stringify(conventionsData, null, 2)}\n`, 'utf8');
491
-
492
- return { profile: resolvedProfile, profilePath, conventionsPath };
493
- }
494
-
495
- export interface FixtureExportInfo {
496
- name: string;
497
- sourceFile: string;
498
- reExportedFrom?: string | undefined;
499
- scope: 'test' | 'worker';
500
- dependencies: string[];
501
- }
502
-
503
- export interface PomExportInfo {
504
- className: string;
505
- sourceFile: string;
506
- extendsClass?: string | undefined;
507
- methods: string[];
508
- }
509
-
510
- export interface HelperExportInfo {
511
- name: string;
512
- kind: 'function' | 'class' | 'const';
513
- sourceFile: string;
514
- category: 'api-client' | 'test-data-factory' | 'helper' | 'matcher';
515
- }
516
-
517
- export interface PlaywrightProjectInfo {
518
- name: string;
519
- dependencies: string[];
520
- storageState?: string | undefined;
521
- }
522
-
523
- export interface BrownfieldRepositoryGraph {
524
- topology: 'monorepo' | 'standalone';
525
- testImport: string;
526
- fixtureExports: FixtureExportInfo[];
527
- pomExports: PomExportInfo[];
528
- helperExports: HelperExportInfo[];
529
- authProjects: PlaywrightProjectInfo[];
530
- storageStatePaths: string[];
531
- configPaths: string[];
532
- tsconfigPaths: string[];
533
- pathAliases: Record<string, string[]>;
534
- packageScripts: Record<string, string>;
535
- sourceHints: {
536
- routes: string[];
537
- validationRules: string[];
538
- apiEndpoints: string[];
539
- authRequired: boolean;
540
- persistenceDetected: boolean;
541
- };
542
- ambiguities: string[];
543
- }
544
-
545
- function toRel(root: string, filePath: string): string {
546
- return path.relative(root, filePath).replace(/\\/g, '/');
547
- }
548
-
549
- export async function buildBrownfieldRepositoryGraph(root: string): Promise<BrownfieldRepositoryGraph> {
550
- const projectRoot = path.resolve(root);
551
- const manifest = await inspectRepository(projectRoot);
552
- const sourceFiles = await listSourceFiles(projectRoot);
553
- const ambiguities: string[] = [];
554
-
555
- const fileContents = new Map<string, string>();
556
- await Promise.all(
557
- sourceFiles.map(async (file) => {
558
- try {
559
- fileContents.set(file, await fs.readFile(file, 'utf8'));
560
- } catch {
561
- fileContents.set(file, '');
562
- }
563
- }),
564
- );
565
-
566
- // 1. Inspect tsconfig aliases
567
- const tsconfigPaths: string[] = [];
568
- const pathAliases: Record<string, string[]> = {};
569
- for (const candidate of ['tsconfig.json', 'tsconfig.tests.json', 'e2e/tsconfig.json']) {
570
- const full = path.join(projectRoot, candidate);
571
- try {
572
- const raw = await fs.readFile(full, 'utf8');
573
- tsconfigPaths.push(candidate);
574
- const cleaned = raw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '');
575
- const parsed = JSON.parse(cleaned) as { compilerOptions?: { paths?: Record<string, string[]> } };
576
- if (parsed?.compilerOptions?.paths) {
577
- Object.assign(pathAliases, parsed.compilerOptions.paths);
578
- }
579
- } catch {
580
- // Ignore invalid/non-existent tsconfig
581
- }
582
- }
583
-
584
- // 2. Inspect Playwright configs for projects & storageState
585
- const authProjects: PlaywrightProjectInfo[] = [];
586
- const storageStateSet = new Set<string>();
587
- for (const cfgRel of manifest.playwrightConfigs) {
588
- const content = fileContents.get(path.join(projectRoot, cfgRel)) ?? await fs.readFile(path.join(projectRoot, cfgRel), 'utf8').catch(() => '');
589
- for (const m of content.matchAll(/storageState\s*:\s*['"`]([^'"`]+)['"`]/g)) {
590
- if (m[1]) storageStateSet.add(m[1]);
591
- }
592
- for (const projMatch of content.matchAll(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]([\s\S]{0,300}?)\}/g)) {
593
- const name = projMatch[1]!;
594
- const body = projMatch[2] ?? '';
595
- const depsMatch = body.match(/dependencies\s*:\s*\[([^\]]*)\]/);
596
- const deps = depsMatch?.[1]
597
- ? [...depsMatch[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]!)
598
- : [];
599
- const ssMatch = body.match(/storageState\s*:\s*['"`]([^'"`]+)['"`]/);
600
- if (ssMatch?.[1]) storageStateSet.add(ssMatch[1]);
601
- if (deps.length > 0 || ssMatch || /auth|setup/i.test(name)) {
602
- authProjects.push({ name, dependencies: deps, ...(ssMatch?.[1] ? { storageState: ssMatch[1] } : {}) });
603
- }
604
- }
605
- }
606
-
607
- // 3. Parse fixtures, barrel re-exports, POMs, helpers via ts-morph
608
- const tsProject = new Project({ useInMemoryFileSystem: true });
609
- const fixtureExports: FixtureExportInfo[] = [];
610
- const pomExports: PomExportInfo[] = [];
611
- const helperExports: HelperExportInfo[] = [];
612
- const reExportMap = new Map<string, string[]>(); // sourceRel -> barrelRel[]
613
- const testEntryCandidates: string[] = [];
614
-
615
- for (const [fullPath, content] of fileContents.entries()) {
616
- const rel = toRel(projectRoot, fullPath);
617
- if (!/\.(?:ts|tsx|js|jsx|mjs)$/i.test(rel)) continue;
618
- const isTestRelated = /fixture|page|pom|helper|util|factory|setup|spec|test|matcher|client/i.test(rel)
619
- || content.includes('base.extend')
620
- || content.includes('@playwright/test');
621
- if (!isTestRelated) continue;
622
-
623
- let sf;
624
- try {
625
- sf = tsProject.createSourceFile(rel, content, { overwrite: true });
626
- } catch {
627
- continue;
628
- }
629
-
630
- // Check barrel re-exports
631
- for (const expDecl of sf.getExportDeclarations()) {
632
- const modSpec = expDecl.getModuleSpecifierValue();
633
- if (!modSpec || !modSpec.startsWith('.')) continue;
634
- const targetRel = path.posix.normalize(path.posix.join(path.posix.dirname(rel), modSpec)).replace(/\.(?:js|ts)$/, '');
635
- const list = reExportMap.get(targetRel) ?? [];
636
- list.push(rel);
637
- reExportMap.set(targetRel, list);
638
- if (/fixture|test/i.test(rel) && expDecl.getNamedExports().some((n) => n.getName() === 'test')) {
639
- testEntryCandidates.push(rel);
640
- }
641
- }
642
-
643
- // Check base.extend({ ... }) or test.extend({ ... })
644
- if (/(?:base|test)\.extend\b/.test(content)) {
645
- testEntryCandidates.push(rel);
646
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
647
- const exprText = call.getExpression().getText();
648
- if (!/(?:base|test)\.extend\s*$/.test(exprText) && !exprText.endsWith('.extend')) continue;
649
- const firstArg = call.getArguments()[0];
650
- if (!firstArg) continue;
651
- const argText = firstArg.getText();
652
- const scope: 'test' | 'worker' = /scope\s*:\s*['"]worker['"]/.test(argText) ? 'worker' : 'test';
653
- for (const prop of firstArg.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
654
- if (prop.getParent() !== firstArg) continue;
655
- const fixtureName = prop.getName();
656
- const fnText = prop.getInitializer()?.getText() ?? '';
657
- const depMatch = fnText.match(/async\s*\(\s*\{\s*([^}]+)\}/);
658
- const deps = depMatch?.[1]
659
- ? depMatch[1].split(',').map((d) => d.trim().split(':')[0]!.trim()).filter(Boolean)
660
- : [];
661
- fixtureExports.push({
662
- name: fixtureName,
663
- sourceFile: rel,
664
- scope,
665
- dependencies: deps,
666
- });
667
- }
668
- }
669
- }
670
-
671
- // Check POM classes
672
- for (const cls of sf.getClasses()) {
673
- const clsName = cls.getName();
674
- if (!clsName) continue;
675
- if (clsName.endsWith('Page') || /pages?[\\/]/i.test(rel) || content.includes('Locator')) {
676
- const ext = cls.getExtends()?.getText();
677
- const methods = cls.getMethods().map((m) => m.getName());
678
- pomExports.push({
679
- className: clsName,
680
- sourceFile: rel,
681
- ...(ext ? { extendsClass: ext } : {}),
682
- methods,
683
- });
684
- }
685
- }
686
-
687
- // Check helpers / factories / API clients / custom matchers
688
- if (/helper|util|factory|factories|api-client|matcher|support/i.test(rel) && !/\.(?:spec|test)\./i.test(rel)) {
689
- const category: HelperExportInfo['category'] = /matcher/i.test(rel)
690
- ? 'matcher'
691
- : /factory|factories|seed|data/i.test(rel)
692
- ? 'test-data-factory'
693
- : /api|client/i.test(rel)
694
- ? 'api-client'
695
- : 'helper';
696
- for (const fn of sf.getFunctions()) {
697
- if (fn.isExported() && fn.getName()) {
698
- helperExports.push({ name: fn.getName()!, kind: 'function', sourceFile: rel, category });
699
- }
700
- }
701
- for (const cls of sf.getClasses()) {
702
- if (cls.isExported() && cls.getName()) {
703
- helperExports.push({ name: cls.getName()!, kind: 'class', sourceFile: rel, category });
704
- }
705
- }
706
- }
707
- }
708
-
709
- // Resolve barrel re-exports onto fixtureExports
710
- for (const fx of fixtureExports) {
711
- const noExt = fx.sourceFile.replace(/\.(?:ts|tsx|js|jsx|mjs)$/, '');
712
- const noIndex = noExt.replace(/\/index$/, '');
713
- const barrels = reExportMap.get(noExt) ?? reExportMap.get(noIndex);
714
- if (barrels && barrels.length > 0) {
715
- fx.reExportedFrom = barrels[0];
716
- }
717
- }
718
-
719
- // Pick authoritative testImport
720
- const barrelFixture = testEntryCandidates.find((c) => /index\.ts$/i.test(c) || /fixtures?\.ts$/i.test(c));
721
- const testImport = barrelFixture ?? testEntryCandidates[0] ?? '@playwright/test';
722
- if (testEntryCandidates.length > 1 && !barrelFixture) {
723
- ambiguities.push(`Multiple fixture entry points detected: ${testEntryCandidates.join(', ')}`);
724
- }
725
-
726
- // 4. Monorepo vs Standalone detection & Source Hints
727
- const routes = new Set<string>();
728
- const validationRules = new Set<string>();
729
- const apiEndpoints = new Set<string>();
730
- let authRequired = false;
731
- let persistenceDetected = false;
732
- let appSourceCount = 0;
733
-
734
- for (const [fullPath, content] of fileContents.entries()) {
735
- const rel = toRel(projectRoot, fullPath);
736
- if (/\.(?:spec|test)\./i.test(rel) || /tests?[\\/]|e2e[\\/]|playwright/i.test(rel)) continue;
737
-
738
- const appRoute = rel.match(/^(?:src\/)?app\/(.+?)\/page\.[tj]sx?$/i);
739
- if (appRoute?.[1]) {
740
- routes.add(`/${appRoute[1]}`);
741
- appSourceCount += 1;
742
- } else if (/^(?:src\/)?app\/page\.[tj]sx?$/i.test(rel)) {
743
- routes.add('/');
744
- appSourceCount += 1;
745
- }
746
- const pagesRoute = rel.match(/^(?:src\/)?pages\/(.+?)\.[tj]sx?$/i);
747
- if (pagesRoute?.[1] && !pagesRoute[1].startsWith('api/')) {
748
- routes.add(pagesRoute[1] === 'index' ? '/' : `/${pagesRoute[1].replace(/\/index$/, '')}`);
749
- appSourceCount += 1;
750
- }
751
- if (/^(?:src\/)?(?:components|routes|views|features)[\\/]/i.test(rel) || /^(?:src\/)?App\.[tj]sx?$/i.test(rel)) {
752
- appSourceCount += 1;
753
- if (routes.size === 0) routes.add('/');
754
- }
755
-
756
- for (const m of content.matchAll(/path\s*:\s*['"](\/[^'"]*)['"]/g)) {
757
- if (m[1]) routes.add(m[1]);
758
- }
759
- for (const m of content.matchAll(/['"`](\/api\/[A-Za-z0-9_/?=&{}:-]+)['"`]/g)) {
760
- if (m[1]) apiEndpoints.add(m[1]);
761
- }
762
- for (const m of content.matchAll(/\.(min|max|email|regex)\s*\(([^)]*)\)/g)) {
763
- validationRules.add(`${m[1]}(${(m[2] ?? '').trim()})`);
764
- }
765
- if (/\b(?:getSession|requireAuth|authMiddleware|isAuthenticated|useAuth|NextAuth)\b/.test(content)) {
766
- authRequired = true;
767
- }
768
- if (/\b(?:localStorage\.setItem|prisma\.|pool\.query|drizzle|mongoose\.model)\b/.test(content)) {
769
- persistenceDetected = true;
770
- }
771
- }
772
-
773
- const topology: 'monorepo' | 'standalone' = appSourceCount > 0 || routes.size > 0 ? 'monorepo' : 'standalone';
774
-
775
- return {
776
- topology,
777
- testImport,
778
- fixtureExports,
779
- pomExports,
780
- helperExports,
781
- authProjects,
782
- storageStatePaths: [...storageStateSet],
783
- configPaths: manifest.playwrightConfigs,
784
- tsconfigPaths,
785
- pathAliases,
786
- packageScripts: manifest.scripts,
787
- sourceHints: {
788
- routes: [...routes],
789
- validationRules: [...validationRules].slice(0, 30),
790
- apiEndpoints: [...apiEndpoints].slice(0, 30),
791
- authRequired,
792
- persistenceDetected,
793
- },
794
- ambiguities,
795
- };
796
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { Project, SyntaxKind } from 'ts-morph';
5
+ import { inspectRepository, RepositoryManifest } from '../evaluation/repository-inspector.js';
6
+
7
+ export const REPOSITORY_PROFILE_VERSION = 1;
8
+
9
+ export interface IdentifierConventions {
10
+ propertySuffixes: string[];
11
+ methodVerbs: Record<string, number>;
12
+ usesReadonly: boolean;
13
+ pageInjection: 'constructor-param-property' | 'constructor-assignment' | 'getter' | 'inline';
14
+ gotoMethodName: string;
15
+ recommendedLocatorPolicy: 'prefer-role-over-css' | 'prefer-testid' | 'prefer-label' | 'prefer-locator' | 'unknown';
16
+ }
17
+
18
+ export interface PageObjectExemplar {
19
+ filePath: string;
20
+ className: string;
21
+ content: string;
22
+ }
23
+
24
+ export interface RepositoryConventionProfile {
25
+ version: typeof REPOSITORY_PROFILE_VERSION;
26
+ generatedAt: string;
27
+ /** Fingerprint of source paths and metadata used to detect convention drift. */
28
+ sourceFingerprint?: string | undefined;
29
+ repository: {
30
+ name: string;
31
+ root: string;
32
+ packageManager: RepositoryManifest['packageManager'];
33
+ playwrightVersion?: string | undefined;
34
+ scripts: Record<string, string>;
35
+ };
36
+ structure: {
37
+ testFiles: number;
38
+ /** Test files whose source explicitly references Playwright. */
39
+ playwrightTestFiles?: number | undefined;
40
+ pageObjectFiles: number;
41
+ fixtureFiles: number;
42
+ configFiles: string[];
43
+ usesPageObjects: boolean;
44
+ usesCustomFixtures: boolean;
45
+ usesApiHelpers: boolean;
46
+ usesStorageState: boolean;
47
+ };
48
+ paths?: {
49
+ testDirectory?: string | undefined;
50
+ pageObjectDirectory?: string | undefined;
51
+ fixtureDirectory?: string | undefined;
52
+ };
53
+ pathConfidence?: {
54
+ testDirectory: number;
55
+ pageObjectDirectory: number;
56
+ fixtureDirectory: number;
57
+ } | undefined;
58
+ locatorConventions: {
59
+ getByRole: number;
60
+ getByTestId: number;
61
+ getByLabel: number;
62
+ getByText: number;
63
+ locator: number;
64
+ cssOrXpath: number;
65
+ preferred: 'getByRole' | 'getByTestId' | 'getByLabel' | 'getByText' | 'locator' | 'unknown';
66
+ };
67
+ identifierConventions?: IdentifierConventions | undefined;
68
+ exemplars?: PageObjectExemplar[] | undefined;
69
+ testConventions: {
70
+ tags: string[];
71
+ usesDescribeBlocks: boolean;
72
+ usesTestSteps: boolean;
73
+ usesAssertions: boolean;
74
+ baseUrl?: string | undefined;
75
+ };
76
+ domainTerms: string[];
77
+ evidence: string[];
78
+ }
79
+
80
+ async function listSourceFiles(root: string, depth = 0): Promise<string[]> {
81
+ if (depth > 6) return [];
82
+ const ignored = new Set([
83
+ 'node_modules', '.git', 'dist', 'build', 'coverage', 'test-results',
84
+ 'playwright-report', 'reports', '.next', '.turbo', '.pie', '.pw-intelligence',
85
+ 'repositories', 'pie-evaluation', 'generation', 'repair-worktrees',
86
+ 'temp', 'tmp'
87
+ ]);
88
+ let entries;
89
+ try {
90
+ entries = await fs.readdir(root, { withFileTypes: true });
91
+ } catch {
92
+ return [];
93
+ }
94
+ const files: string[] = [];
95
+ for (const entry of entries) {
96
+ if (ignored.has(entry.name)) continue;
97
+ const fullPath = path.join(root, entry.name);
98
+ if (entry.isDirectory()) files.push(...await listSourceFiles(fullPath, depth + 1));
99
+ else if (/\.(?:ts|tsx|js|jsx|mjs|cjs)$/i.test(entry.name) && !/\.d\.ts$/i.test(entry.name)) files.push(fullPath);
100
+ }
101
+ return files;
102
+ }
103
+
104
+ /** Returns whether a directory has enough project markers to profile safely. */
105
+ export async function isLikelyProjectRoot(root: string): Promise<boolean> {
106
+ const projectRoot = path.resolve(root);
107
+ const markers = ['package.json', 'playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs', 'playwright.config.cjs'];
108
+ for (const marker of markers) {
109
+ try {
110
+ await fs.access(path.join(projectRoot, marker));
111
+ return true;
112
+ } catch {
113
+ // Try the next project marker.
114
+ }
115
+ }
116
+ return false;
117
+ }
118
+
119
+ /** Detects evaluation/workspace containers that must not be profiled recursively. */
120
+ export async function isLikelyRepositoryContainer(root: string): Promise<boolean> {
121
+ const containerNames = new Set(['repositories', 'pie-evaluation', 'generation', 'repair-worktrees', 'generated-test-data']);
122
+ try {
123
+ const entries = await fs.readdir(path.resolve(root), { withFileTypes: true });
124
+ return entries.some((entry) => entry.isDirectory() && containerNames.has(entry.name));
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ function countMatches(contents: string, pattern: RegExp): number {
131
+ return contents.match(pattern)?.length ?? 0;
132
+ }
133
+
134
+ function preferredLocator(counts: Omit<RepositoryConventionProfile['locatorConventions'], 'preferred'>): RepositoryConventionProfile['locatorConventions']['preferred'] {
135
+ // Policy order: role > label > testid > text > locator
136
+ if (counts.getByRole > 0) return 'getByRole';
137
+ if (counts.getByLabel > 0) return 'getByLabel';
138
+ if (counts.getByTestId > 0) return 'getByTestId';
139
+ if (counts.getByText > 0) return 'getByText';
140
+ if (counts.locator > 0) return 'locator';
141
+ return 'unknown';
142
+ }
143
+
144
+ async function sampleIdentifierConventions(
145
+ pomFiles: string[],
146
+ locatorCounts: Omit<RepositoryConventionProfile['locatorConventions'], 'preferred'>
147
+ ): Promise<IdentifierConventions> {
148
+ const propertySuffixesCount = new Map<string, number>();
149
+ const methodVerbs: Record<string, number> = {};
150
+ let readonlyCount = 0;
151
+ let totalProperties = 0;
152
+ let pageInjection: IdentifierConventions['pageInjection'] = 'constructor-assignment';
153
+ let gotoMethodName = 'goto';
154
+
155
+ const knownSuffixes = ['Button', 'Input', 'Link', 'Select', 'Checkbox', 'Dialog', 'Modal', 'Heading', 'Cell', 'Table', 'Tab', 'Radio', 'Status', 'Card'];
156
+ const sampleFiles = pomFiles.slice(0, 5);
157
+
158
+ if (sampleFiles.length > 0) {
159
+ try {
160
+ const project = new Project({ useInMemoryFileSystem: true });
161
+ for (const filePath of sampleFiles) {
162
+ try {
163
+ const content = await fs.readFile(filePath, 'utf8');
164
+ const sourceFile = project.createSourceFile(path.basename(filePath), content, { overwrite: true });
165
+
166
+ for (const cls of sourceFile.getClasses()) {
167
+ const ctor = cls.getConstructors()[0];
168
+ if (ctor) {
169
+ const params = ctor.getParameters();
170
+ if (params.some((p) => p.getScope() || p.isReadonly())) {
171
+ pageInjection = 'constructor-param-property';
172
+ }
173
+ }
174
+
175
+ for (const prop of cls.getProperties()) {
176
+ totalProperties++;
177
+ if (prop.isReadonly()) readonlyCount++;
178
+ const name = prop.getName();
179
+ for (const suffix of knownSuffixes) {
180
+ if (name.endsWith(suffix)) {
181
+ propertySuffixesCount.set(suffix, (propertySuffixesCount.get(suffix) ?? 0) + 1);
182
+ }
183
+ }
184
+ }
185
+
186
+ for (const getter of cls.getGetAccessors()) {
187
+ const name = getter.getName();
188
+ for (const suffix of knownSuffixes) {
189
+ if (name.endsWith(suffix)) {
190
+ propertySuffixesCount.set(suffix, (propertySuffixesCount.get(suffix) ?? 0) + 1);
191
+ }
192
+ }
193
+ }
194
+
195
+ for (const method of cls.getMethods()) {
196
+ const name = method.getName();
197
+ if (name === 'goto' || name === 'open' || name === 'navigate' || name === 'load') {
198
+ gotoMethodName = name;
199
+ }
200
+ const match = name.match(/^([a-z]+)/);
201
+ if (match) {
202
+ const verb = match[1]!;
203
+ methodVerbs[verb] = (methodVerbs[verb] ?? 0) + 1;
204
+ }
205
+ }
206
+ }
207
+ } catch {
208
+ // Continue to next sample file
209
+ }
210
+ }
211
+ } catch {
212
+ // Fallback
213
+ }
214
+ }
215
+
216
+ let recommendedLocatorPolicy: IdentifierConventions['recommendedLocatorPolicy'] = 'unknown';
217
+ if (locatorCounts.getByRole > 0) recommendedLocatorPolicy = 'prefer-role-over-css';
218
+ else if (locatorCounts.getByLabel > 0) recommendedLocatorPolicy = 'prefer-label';
219
+ else if (locatorCounts.getByTestId > 0) recommendedLocatorPolicy = 'prefer-testid';
220
+ else if (locatorCounts.locator > 0) recommendedLocatorPolicy = 'prefer-locator';
221
+
222
+ const propertySuffixes = [...propertySuffixesCount.entries()]
223
+ .sort((a, b) => b[1] - a[1])
224
+ .map(([s]) => s);
225
+ if (propertySuffixes.length === 0) {
226
+ propertySuffixes.push('Button', 'Input');
227
+ }
228
+
229
+ return {
230
+ propertySuffixes,
231
+ methodVerbs,
232
+ usesReadonly: totalProperties > 0 ? readonlyCount / totalProperties >= 0.5 : true,
233
+ pageInjection,
234
+ gotoMethodName,
235
+ recommendedLocatorPolicy,
236
+ };
237
+ }
238
+
239
+ export async function sampleExemplars(pomFiles: string[], maxExemplars = 3): Promise<PageObjectExemplar[]> {
240
+ const exemplars: PageObjectExemplar[] = [];
241
+ for (const file of pomFiles.slice(0, maxExemplars)) {
242
+ try {
243
+ const content = await fs.readFile(file, 'utf8');
244
+ const lines = content.split(/\r?\n/).slice(0, 120);
245
+ const match = content.match(/export\s+class\s+([A-Za-z0-9_]+)/);
246
+ const className = match ? match[1]! : path.basename(file, path.extname(file));
247
+ exemplars.push({
248
+ filePath: file,
249
+ className,
250
+ content: lines.join('\n'),
251
+ });
252
+ } catch {}
253
+ }
254
+ return exemplars;
255
+ }
256
+
257
+ function extractDomainTerms(filePaths: string[], contents: string): string[] {
258
+ const ignored = new Set([
259
+ 'test', 'tests', 'spec', 'specs', 'page', 'pages', 'pom', 'fixture', 'fixtures',
260
+ 'should', 'be', 'able', 'to', 'the', 'and', 'with', 'from', 'for', 'using', 'login', 'user',
261
+ 'expect', 'locator', 'bevisible', 'byrole', 'bytext', 'bytestid', 'bylabel', 'haveurl',
262
+ 'eventlistener', 'containtext', 'havetext', 'totruthy', 'toequal', 'waitfor',
263
+ 'click', 'fill', 'press', 'check', 'uncheck', 'select', 'focus', 'hover',
264
+ 'promise', 'async', 'await', 'void', 'string', 'boolean', 'number', 'function',
265
+ 'object', 'array', 'import', 'export', 'default', 'class', 'const', 'return',
266
+ ]);
267
+ const terms = new Map<string, number>();
268
+ const candidates = [
269
+ ...filePaths.flatMap((file) => path.basename(file).replace(/\.(?:spec|test)\.[^.]+$/i, '').split(/[^A-Za-z0-9]+/)),
270
+ ...contents.match(/[A-Z][A-Za-z0-9]{3,}/g) ?? [],
271
+ ];
272
+ for (const candidate of candidates) {
273
+ const term = candidate.toLowerCase();
274
+ if (!ignored.has(term) && /^[a-z][a-z0-9-]{3,}$/.test(term)) terms.set(term, (terms.get(term) ?? 0) + 1);
275
+ }
276
+ return [...terms.entries()].sort((left, right) => right[1] - left[1]).slice(0, 30).map(([term]) => term);
277
+ }
278
+
279
+ export async function extractBaseUrlFromConfigs(configPaths: string[], root: string): Promise<string | undefined> {
280
+ for (const configPath of configPaths) {
281
+ try {
282
+ const fullPath = path.isAbsolute(configPath) ? configPath : path.join(root, configPath);
283
+ const content = await fs.readFile(fullPath, 'utf8');
284
+
285
+ // 1. Direct baseURL: '...' or baseURL: "..." or baseURL: `...`
286
+ const directMatch = content.match(/baseURL:\s*(?:process\.env\.[A-Za-z0-9_]+\s*\|\|\s*)?['"`](https?:\/\/[^'"`]+)['"`]/);
287
+ if (directMatch?.[1]) {
288
+ return directMatch[1];
289
+ }
290
+
291
+ // 2. Identifier baseURL: baseURL: <identifier>
292
+ const identMatch = content.match(/baseURL:\s*([A-Za-z0-9_]+)/);
293
+ if (identMatch?.[1] && identMatch[1] !== 'process') {
294
+ const ident = identMatch[1];
295
+ const declRegex = new RegExp(`(?:const|let|var)\\s+${ident}\\s*=\\s*(?:process\\.env\\.[A-Za-z0-9_]+\\s*\\|\\|\\s*)?['"\`](https?:\\/\\/[^'"\`]+)['"\`]`);
296
+ const declMatch = content.match(declRegex);
297
+ if (declMatch?.[1]) {
298
+ return declMatch[1];
299
+ }
300
+ }
301
+
302
+ // 3. Fallback: match any baseURL: '...' even if relative or local
303
+ const anyMatch = content.match(/baseURL:\s*['"`]([^'"`]+)['"`]/);
304
+ if (anyMatch?.[1]) {
305
+ return anyMatch[1];
306
+ }
307
+ } catch {
308
+ // Continue to next candidate
309
+ }
310
+ }
311
+ return undefined;
312
+ }
313
+
314
+ function mostCommonDirectory(filePaths: string[], root: string, fallback: string): string {
315
+ const counts = new Map<string, number>();
316
+ for (const filePath of filePaths) {
317
+ const relativeDirectory = path.relative(root, path.dirname(filePath)).replace(/\\/g, '/');
318
+ const directory = relativeDirectory && relativeDirectory !== '.' ? relativeDirectory : fallback;
319
+ counts.set(directory, (counts.get(directory) ?? 0) + 1);
320
+ }
321
+ const winner = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
322
+ return winner?.[0] ?? fallback;
323
+ }
324
+
325
+ function directoryConfidence(filePaths: string[], root: string): number {
326
+ if (filePaths.length === 0) return 0;
327
+ const counts = new Map<string, number>();
328
+ for (const filePath of filePaths) {
329
+ const relativeDirectory = path.relative(root, path.dirname(filePath)).replace(/\\/g, '/');
330
+ counts.set(relativeDirectory, (counts.get(relativeDirectory) ?? 0) + 1);
331
+ }
332
+ const strongest = Math.max(...counts.values());
333
+ return Number((strongest / filePaths.length).toFixed(3));
334
+ }
335
+
336
+ function isPlaywrightTestSource(filePath: string, content: string): boolean {
337
+ return /\.(?:spec|test)\.[^.]+$/i.test(filePath) &&
338
+ /(?:@playwright\/test|['"]playwright(?:\/test)?['"]|\b(?:test|expect)\s*\.)/.test(content);
339
+ }
340
+
341
+ /** Computes a cheap change detector without rereading source contents. */
342
+ export async function computeRepositoryProfileFingerprint(root: string): Promise<string> {
343
+ const projectRoot = path.resolve(root);
344
+ const sourceFiles = await listSourceFiles(projectRoot);
345
+ const hash = crypto.createHash('sha256');
346
+ for (const filePath of sourceFiles.sort()) {
347
+ try {
348
+ const stat = await fs.stat(filePath);
349
+ hash.update(path.relative(projectRoot, filePath).replace(/\\/g, '/'));
350
+ hash.update(`\0${stat.size}:${stat.mtimeMs}\0`);
351
+ } catch {
352
+ hash.update(`${path.relative(projectRoot, filePath).replace(/\\/g, '/')}\0missing\0`);
353
+ }
354
+ }
355
+ return hash.digest('hex');
356
+ }
357
+
358
+ export async function buildRepositoryProfile(root: string, now = new Date()): Promise<RepositoryConventionProfile> {
359
+ const manifest = await inspectRepository(root);
360
+ const sourceFiles = await listSourceFiles(path.resolve(root));
361
+ const sourceFingerprint = await computeRepositoryProfileFingerprint(root);
362
+ const sourceRecords = await Promise.all(sourceFiles.map(async (file) => {
363
+ try {
364
+ return [file, await fs.readFile(file, 'utf8')] as const;
365
+ } catch {
366
+ return [file, ''] as const;
367
+ }
368
+ }));
369
+ const sourceContents = sourceRecords.map(([, content]) => content).join('\n');
370
+ const locatorCounts = {
371
+ getByRole: countMatches(sourceContents, /\bgetByRole\s*\(/g),
372
+ getByTestId: countMatches(sourceContents, /\bgetByTestId\s*\(/g),
373
+ getByLabel: countMatches(sourceContents, /\bgetByLabel\s*\(/g),
374
+ getByText: countMatches(sourceContents, /\bgetByText\s*\(/g),
375
+ locator: countMatches(sourceContents, /\.(?:locator|frameLocator)\s*\(/g),
376
+ cssOrXpath: countMatches(sourceContents, /(?:xpath=|css=|page\.locator\s*\(\s*['"`][#.\[])/g),
377
+ };
378
+ const testFiles = sourceFiles.filter((file) => /\.(?:spec|test)\.[^.]+$/i.test(file));
379
+ const playwrightTestFiles = sourceRecords
380
+ .filter(([file, content]) => isPlaywrightTestSource(file, content))
381
+ .map(([file]) => file);
382
+ const profileTestFiles = playwrightTestFiles.length > 0
383
+ ? playwrightTestFiles
384
+ : testFiles;
385
+ const pageObjectFiles = sourceFiles.filter((file) => /(?:page|page-object)\.[^.]+$/i.test(file) || /pages?[\\/]/i.test(file));
386
+ const fixtureFiles = sourceFiles.filter((file) => /fixture\.[^.]+$/i.test(file) || /fixtures?[\\/]/i.test(file));
387
+
388
+ // Restrict tag and domain mining exclusively to source files containing /\btest(?:\.describe)?\s*\(/
389
+ const testSourceRecords = sourceRecords.filter(([file, content]) =>
390
+ /\.(?:spec|test)\.[^.]+$/i.test(file) &&
391
+ !/\.d\.ts$/i.test(file) &&
392
+ !/\.css$/i.test(file) &&
393
+ /\btest(?:\.describe)?\s*\(/.test(content)
394
+ );
395
+ const testSourceContents = testSourceRecords.map(([, content]) => content).join('\n');
396
+ const testSourcePaths = testSourceRecords.map(([file]) => file);
397
+
398
+ const JSDOC_TAGS = new Set([
399
+ '@param', '@returns', '@return', '@type', '@typedef', '@template',
400
+ '@override', '@deprecated', '@see', '@example', '@throws',
401
+ '@ts-expect-error', '@ts-ignore', '@ts-nocheck', '@ts-check',
402
+ '@jest-environment', '@vitest-environment'
403
+ ]);
404
+ const tags = [...new Set([...testSourceContents.matchAll(/(@[A-Za-z][\w-]*)/g)]
405
+ .map((match) => match[1]!)
406
+ .filter((tag) => !JSDOC_TAGS.has(tag.toLowerCase()))
407
+ )].sort();
408
+
409
+ const baseUrl = await extractBaseUrlFromConfigs(manifest.playwrightConfigs, path.resolve(root));
410
+
411
+ const evidence: string[] = [];
412
+ if (manifest.pageObjectFiles > 0) evidence.push('Page Object files detected');
413
+ if (manifest.fixtureFiles > 0) evidence.push('Fixture files detected');
414
+ if (/\b(?:request|apiRequestContext|APIRequestContext)\b/i.test(sourceContents)) evidence.push('API/request helpers detected');
415
+ if (/storageState|storage-state/i.test(sourceContents)) evidence.push('Playwright storage state detected');
416
+ if (manifest.playwrightConfigs.length > 0) evidence.push('Playwright configuration detected');
417
+ if (baseUrl) evidence.push(`Playwright baseURL discovered: ${baseUrl}`);
418
+ if (playwrightTestFiles.length > 0) evidence.push(`${playwrightTestFiles.length} Playwright test source(s) detected`);
419
+ else evidence.push('No Playwright test source detected; generic test files were not treated as Playwright conventions');
420
+
421
+ return {
422
+ version: REPOSITORY_PROFILE_VERSION,
423
+ generatedAt: now.toISOString(),
424
+ sourceFingerprint,
425
+ repository: {
426
+ name: manifest.name,
427
+ root: manifest.root,
428
+ packageManager: manifest.packageManager,
429
+ playwrightVersion: manifest.playwrightVersion,
430
+ scripts: manifest.scripts,
431
+ },
432
+ structure: {
433
+ testFiles: manifest.testFiles,
434
+ playwrightTestFiles: playwrightTestFiles.length,
435
+ pageObjectFiles: manifest.pageObjectFiles,
436
+ fixtureFiles: manifest.fixtureFiles,
437
+ configFiles: manifest.playwrightConfigs,
438
+ usesPageObjects: manifest.pageObjectFiles > 0,
439
+ usesCustomFixtures: manifest.fixtureFiles > 0,
440
+ usesApiHelpers: /\b(?:request|apiRequestContext|APIRequestContext)\b/i.test(sourceContents),
441
+ usesStorageState: /storageState|storage-state/i.test(sourceContents),
442
+ },
443
+ paths: {
444
+ testDirectory: mostCommonDirectory(profileTestFiles, path.resolve(root), 'tests'),
445
+ pageObjectDirectory: mostCommonDirectory(pageObjectFiles, path.resolve(root), 'tests/pages'),
446
+ fixtureDirectory: mostCommonDirectory(fixtureFiles, path.resolve(root), 'tests/fixtures'),
447
+ },
448
+ pathConfidence: {
449
+ testDirectory: directoryConfidence(profileTestFiles, path.resolve(root)),
450
+ pageObjectDirectory: directoryConfidence(pageObjectFiles, path.resolve(root)),
451
+ fixtureDirectory: directoryConfidence(fixtureFiles, path.resolve(root)),
452
+ },
453
+ locatorConventions: { ...locatorCounts, preferred: preferredLocator(locatorCounts) },
454
+ identifierConventions: await sampleIdentifierConventions(pageObjectFiles, locatorCounts),
455
+ exemplars: await sampleExemplars(pageObjectFiles, 3),
456
+ testConventions: {
457
+ tags,
458
+ usesDescribeBlocks: /\btest\.describe\s*\(/.test(testSourceContents || sourceContents),
459
+ usesTestSteps: /\btest\.step\s*\(/.test(testSourceContents || sourceContents),
460
+ usesAssertions: /\bexpect\s*\(/.test(testSourceContents || sourceContents),
461
+ baseUrl,
462
+ },
463
+ domainTerms: extractDomainTerms(testSourcePaths, testSourceContents),
464
+ evidence,
465
+ };
466
+ }
467
+
468
+ export async function writeRepositoryProfile(
469
+ root: string,
470
+ profile?: RepositoryConventionProfile,
471
+ ): Promise<{ profile: RepositoryConventionProfile; profilePath: string; conventionsPath: string }> {
472
+ const projectRoot = path.resolve(root);
473
+ const resolvedProfile = profile ?? await buildRepositoryProfile(projectRoot);
474
+ const pieDir = path.join(projectRoot, '.pie');
475
+ await fs.mkdir(pieDir, { recursive: true });
476
+
477
+ const profilePath = path.join(pieDir, 'repository-profile.json');
478
+ await fs.writeFile(profilePath, `${JSON.stringify(resolvedProfile, null, 2)}\n`, 'utf8');
479
+
480
+ const conventionsPath = path.join(pieDir, 'conventions.json');
481
+ const conventionsData = {
482
+ version: 1,
483
+ generatedAt: resolvedProfile.generatedAt,
484
+ sourceFingerprint: resolvedProfile.sourceFingerprint,
485
+ locatorConventions: resolvedProfile.locatorConventions,
486
+ identifierConventions: resolvedProfile.identifierConventions,
487
+ paths: resolvedProfile.paths,
488
+ structure: resolvedProfile.structure,
489
+ };
490
+ await fs.writeFile(conventionsPath, `${JSON.stringify(conventionsData, null, 2)}\n`, 'utf8');
491
+
492
+ return { profile: resolvedProfile, profilePath, conventionsPath };
493
+ }
494
+
495
+ export interface FixtureExportInfo {
496
+ name: string;
497
+ sourceFile: string;
498
+ reExportedFrom?: string | undefined;
499
+ scope: 'test' | 'worker';
500
+ dependencies: string[];
501
+ }
502
+
503
+ export interface PomExportInfo {
504
+ className: string;
505
+ sourceFile: string;
506
+ extendsClass?: string | undefined;
507
+ methods: string[];
508
+ }
509
+
510
+ export interface HelperExportInfo {
511
+ name: string;
512
+ kind: 'function' | 'class' | 'const';
513
+ sourceFile: string;
514
+ category: 'api-client' | 'test-data-factory' | 'helper' | 'matcher';
515
+ }
516
+
517
+ export interface PlaywrightProjectInfo {
518
+ name: string;
519
+ dependencies: string[];
520
+ storageState?: string | undefined;
521
+ }
522
+
523
+ export interface BrownfieldRepositoryGraph {
524
+ topology: 'monorepo' | 'standalone';
525
+ testImport: string;
526
+ fixtureExports: FixtureExportInfo[];
527
+ pomExports: PomExportInfo[];
528
+ helperExports: HelperExportInfo[];
529
+ authProjects: PlaywrightProjectInfo[];
530
+ storageStatePaths: string[];
531
+ configPaths: string[];
532
+ tsconfigPaths: string[];
533
+ pathAliases: Record<string, string[]>;
534
+ packageScripts: Record<string, string>;
535
+ sourceHints: {
536
+ routes: string[];
537
+ validationRules: string[];
538
+ apiEndpoints: string[];
539
+ authRequired: boolean;
540
+ persistenceDetected: boolean;
541
+ };
542
+ ambiguities: string[];
543
+ }
544
+
545
+ function toRel(root: string, filePath: string): string {
546
+ return path.relative(root, filePath).replace(/\\/g, '/');
547
+ }
548
+
549
+ export async function buildBrownfieldRepositoryGraph(root: string): Promise<BrownfieldRepositoryGraph> {
550
+ const projectRoot = path.resolve(root);
551
+ const manifest = await inspectRepository(projectRoot);
552
+ const sourceFiles = await listSourceFiles(projectRoot);
553
+ const ambiguities: string[] = [];
554
+
555
+ const fileContents = new Map<string, string>();
556
+ await Promise.all(
557
+ sourceFiles.map(async (file) => {
558
+ try {
559
+ fileContents.set(file, await fs.readFile(file, 'utf8'));
560
+ } catch {
561
+ fileContents.set(file, '');
562
+ }
563
+ }),
564
+ );
565
+
566
+ // 1. Inspect tsconfig aliases
567
+ const tsconfigPaths: string[] = [];
568
+ const pathAliases: Record<string, string[]> = {};
569
+ for (const candidate of ['tsconfig.json', 'tsconfig.tests.json', 'e2e/tsconfig.json']) {
570
+ const full = path.join(projectRoot, candidate);
571
+ try {
572
+ const raw = await fs.readFile(full, 'utf8');
573
+ tsconfigPaths.push(candidate);
574
+ const cleaned = raw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '');
575
+ const parsed = JSON.parse(cleaned) as { compilerOptions?: { paths?: Record<string, string[]> } };
576
+ if (parsed?.compilerOptions?.paths) {
577
+ Object.assign(pathAliases, parsed.compilerOptions.paths);
578
+ }
579
+ } catch {
580
+ // Ignore invalid/non-existent tsconfig
581
+ }
582
+ }
583
+
584
+ // 2. Inspect Playwright configs for projects & storageState
585
+ const authProjects: PlaywrightProjectInfo[] = [];
586
+ const storageStateSet = new Set<string>();
587
+ for (const cfgRel of manifest.playwrightConfigs) {
588
+ const content = fileContents.get(path.join(projectRoot, cfgRel)) ?? await fs.readFile(path.join(projectRoot, cfgRel), 'utf8').catch(() => '');
589
+ for (const m of content.matchAll(/storageState\s*:\s*['"`]([^'"`]+)['"`]/g)) {
590
+ if (m[1]) storageStateSet.add(m[1]);
591
+ }
592
+ for (const projMatch of content.matchAll(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]([\s\S]{0,300}?)\}/g)) {
593
+ const name = projMatch[1]!;
594
+ const body = projMatch[2] ?? '';
595
+ const depsMatch = body.match(/dependencies\s*:\s*\[([^\]]*)\]/);
596
+ const deps = depsMatch?.[1]
597
+ ? [...depsMatch[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]!)
598
+ : [];
599
+ const ssMatch = body.match(/storageState\s*:\s*['"`]([^'"`]+)['"`]/);
600
+ if (ssMatch?.[1]) storageStateSet.add(ssMatch[1]);
601
+ if (deps.length > 0 || ssMatch || /auth|setup/i.test(name)) {
602
+ authProjects.push({ name, dependencies: deps, ...(ssMatch?.[1] ? { storageState: ssMatch[1] } : {}) });
603
+ }
604
+ }
605
+ }
606
+
607
+ // 3. Parse fixtures, barrel re-exports, POMs, helpers via ts-morph
608
+ const tsProject = new Project({ useInMemoryFileSystem: true });
609
+ const fixtureExports: FixtureExportInfo[] = [];
610
+ const pomExports: PomExportInfo[] = [];
611
+ const helperExports: HelperExportInfo[] = [];
612
+ const reExportMap = new Map<string, string[]>(); // sourceRel -> barrelRel[]
613
+ const testEntryCandidates: string[] = [];
614
+
615
+ for (const [fullPath, content] of fileContents.entries()) {
616
+ const rel = toRel(projectRoot, fullPath);
617
+ if (!/\.(?:ts|tsx|js|jsx|mjs)$/i.test(rel)) continue;
618
+ const isTestRelated = /fixture|page|pom|helper|util|factory|setup|spec|test|matcher|client/i.test(rel)
619
+ || content.includes('base.extend')
620
+ || content.includes('@playwright/test');
621
+ if (!isTestRelated) continue;
622
+
623
+ let sf;
624
+ try {
625
+ sf = tsProject.createSourceFile(rel, content, { overwrite: true });
626
+ } catch {
627
+ continue;
628
+ }
629
+
630
+ // Check barrel re-exports
631
+ for (const expDecl of sf.getExportDeclarations()) {
632
+ const modSpec = expDecl.getModuleSpecifierValue();
633
+ if (!modSpec || !modSpec.startsWith('.')) continue;
634
+ const targetRel = path.posix.normalize(path.posix.join(path.posix.dirname(rel), modSpec)).replace(/\.(?:js|ts)$/, '');
635
+ const list = reExportMap.get(targetRel) ?? [];
636
+ list.push(rel);
637
+ reExportMap.set(targetRel, list);
638
+ if (/fixture|test/i.test(rel) && expDecl.getNamedExports().some((n) => n.getName() === 'test')) {
639
+ testEntryCandidates.push(rel);
640
+ }
641
+ }
642
+
643
+ // Check base.extend({ ... }) or test.extend({ ... })
644
+ if (/(?:base|test)\.extend\b/.test(content)) {
645
+ testEntryCandidates.push(rel);
646
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
647
+ const exprText = call.getExpression().getText();
648
+ if (!/(?:base|test)\.extend\s*$/.test(exprText) && !exprText.endsWith('.extend')) continue;
649
+ const firstArg = call.getArguments()[0];
650
+ if (!firstArg) continue;
651
+ const argText = firstArg.getText();
652
+ const scope: 'test' | 'worker' = /scope\s*:\s*['"]worker['"]/.test(argText) ? 'worker' : 'test';
653
+ for (const prop of firstArg.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
654
+ if (prop.getParent() !== firstArg) continue;
655
+ const fixtureName = prop.getName();
656
+ const fnText = prop.getInitializer()?.getText() ?? '';
657
+ const depMatch = fnText.match(/async\s*\(\s*\{\s*([^}]+)\}/);
658
+ const deps = depMatch?.[1]
659
+ ? depMatch[1].split(',').map((d) => d.trim().split(':')[0]!.trim()).filter(Boolean)
660
+ : [];
661
+ fixtureExports.push({
662
+ name: fixtureName,
663
+ sourceFile: rel,
664
+ scope,
665
+ dependencies: deps,
666
+ });
667
+ }
668
+ }
669
+ }
670
+
671
+ // Check POM classes
672
+ for (const cls of sf.getClasses()) {
673
+ const clsName = cls.getName();
674
+ if (!clsName) continue;
675
+ if (clsName.endsWith('Page') || /pages?[\\/]/i.test(rel) || content.includes('Locator')) {
676
+ const ext = cls.getExtends()?.getText();
677
+ const methods = cls.getMethods().map((m) => m.getName());
678
+ pomExports.push({
679
+ className: clsName,
680
+ sourceFile: rel,
681
+ ...(ext ? { extendsClass: ext } : {}),
682
+ methods,
683
+ });
684
+ }
685
+ }
686
+
687
+ // Check helpers / factories / API clients / custom matchers
688
+ if (/helper|util|factory|factories|api-client|matcher|support/i.test(rel) && !/\.(?:spec|test)\./i.test(rel)) {
689
+ const category: HelperExportInfo['category'] = /matcher/i.test(rel)
690
+ ? 'matcher'
691
+ : /factory|factories|seed|data/i.test(rel)
692
+ ? 'test-data-factory'
693
+ : /api|client/i.test(rel)
694
+ ? 'api-client'
695
+ : 'helper';
696
+ for (const fn of sf.getFunctions()) {
697
+ if (fn.isExported() && fn.getName()) {
698
+ helperExports.push({ name: fn.getName()!, kind: 'function', sourceFile: rel, category });
699
+ }
700
+ }
701
+ for (const cls of sf.getClasses()) {
702
+ if (cls.isExported() && cls.getName()) {
703
+ helperExports.push({ name: cls.getName()!, kind: 'class', sourceFile: rel, category });
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ // Resolve barrel re-exports onto fixtureExports
710
+ for (const fx of fixtureExports) {
711
+ const noExt = fx.sourceFile.replace(/\.(?:ts|tsx|js|jsx|mjs)$/, '');
712
+ const noIndex = noExt.replace(/\/index$/, '');
713
+ const barrels = reExportMap.get(noExt) ?? reExportMap.get(noIndex);
714
+ if (barrels && barrels.length > 0) {
715
+ fx.reExportedFrom = barrels[0];
716
+ }
717
+ }
718
+
719
+ // Pick authoritative testImport
720
+ const barrelFixture = testEntryCandidates.find((c) => /index\.ts$/i.test(c) || /fixtures?\.ts$/i.test(c));
721
+ const testImport = barrelFixture ?? testEntryCandidates[0] ?? '@playwright/test';
722
+ if (testEntryCandidates.length > 1 && !barrelFixture) {
723
+ ambiguities.push(`Multiple fixture entry points detected: ${testEntryCandidates.join(', ')}`);
724
+ }
725
+
726
+ // 4. Monorepo vs Standalone detection & Source Hints
727
+ const routes = new Set<string>();
728
+ const validationRules = new Set<string>();
729
+ const apiEndpoints = new Set<string>();
730
+ let authRequired = false;
731
+ let persistenceDetected = false;
732
+ let appSourceCount = 0;
733
+
734
+ for (const [fullPath, content] of fileContents.entries()) {
735
+ const rel = toRel(projectRoot, fullPath);
736
+ if (/\.(?:spec|test)\./i.test(rel) || /tests?[\\/]|e2e[\\/]|playwright/i.test(rel)) continue;
737
+
738
+ const appRoute = rel.match(/^(?:src\/)?app\/(.+?)\/page\.[tj]sx?$/i);
739
+ if (appRoute?.[1]) {
740
+ routes.add(`/${appRoute[1]}`);
741
+ appSourceCount += 1;
742
+ } else if (/^(?:src\/)?app\/page\.[tj]sx?$/i.test(rel)) {
743
+ routes.add('/');
744
+ appSourceCount += 1;
745
+ }
746
+ const pagesRoute = rel.match(/^(?:src\/)?pages\/(.+?)\.[tj]sx?$/i);
747
+ if (pagesRoute?.[1] && !pagesRoute[1].startsWith('api/')) {
748
+ routes.add(pagesRoute[1] === 'index' ? '/' : `/${pagesRoute[1].replace(/\/index$/, '')}`);
749
+ appSourceCount += 1;
750
+ }
751
+ if (/^(?:src\/)?(?:components|routes|views|features)[\\/]/i.test(rel) || /^(?:src\/)?App\.[tj]sx?$/i.test(rel)) {
752
+ appSourceCount += 1;
753
+ if (routes.size === 0) routes.add('/');
754
+ }
755
+
756
+ for (const m of content.matchAll(/path\s*:\s*['"](\/[^'"]*)['"]/g)) {
757
+ if (m[1]) routes.add(m[1]);
758
+ }
759
+ for (const m of content.matchAll(/['"`](\/api\/[A-Za-z0-9_/?=&{}:-]+)['"`]/g)) {
760
+ if (m[1]) apiEndpoints.add(m[1]);
761
+ }
762
+ for (const m of content.matchAll(/\.(min|max|email|regex)\s*\(([^)]*)\)/g)) {
763
+ validationRules.add(`${m[1]}(${(m[2] ?? '').trim()})`);
764
+ }
765
+ if (/\b(?:getSession|requireAuth|authMiddleware|isAuthenticated|useAuth|NextAuth)\b/.test(content)) {
766
+ authRequired = true;
767
+ }
768
+ if (/\b(?:localStorage\.setItem|prisma\.|pool\.query|drizzle|mongoose\.model)\b/.test(content)) {
769
+ persistenceDetected = true;
770
+ }
771
+ }
772
+
773
+ const topology: 'monorepo' | 'standalone' = appSourceCount > 0 || routes.size > 0 ? 'monorepo' : 'standalone';
774
+
775
+ return {
776
+ topology,
777
+ testImport,
778
+ fixtureExports,
779
+ pomExports,
780
+ helperExports,
781
+ authProjects,
782
+ storageStatePaths: [...storageStateSet],
783
+ configPaths: manifest.playwrightConfigs,
784
+ tsconfigPaths,
785
+ pathAliases,
786
+ packageScripts: manifest.scripts,
787
+ sourceHints: {
788
+ routes: [...routes],
789
+ validationRules: [...validationRules].slice(0, 30),
790
+ apiEndpoints: [...apiEndpoints].slice(0, 30),
791
+ authRequired,
792
+ persistenceDetected,
793
+ },
794
+ ambiguities,
795
+ };
796
+ }