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,197 +1,197 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import path from 'node:path';
3
- import { RepositoryManifest } from './repository-inspector.js';
4
-
5
- export interface RepositoryRunOptions {
6
- script?: string | undefined;
7
- timeoutMs?: number | undefined;
8
- environment?: Record<string, string> | undefined;
9
- }
10
-
11
- export interface RepositoryRunResult {
12
- repository: string;
13
- command: string;
14
- args: string[];
15
- cwd: string;
16
- status: 'PASS' | 'FAIL' | 'TIMEOUT' | 'BLOCKED';
17
- exitCode: number | null;
18
- signal?: string | undefined;
19
- stdout: string;
20
- stderr: string;
21
- durationMs: number;
22
- reason?: string | undefined;
23
- }
24
-
25
- const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
26
- const SAFE_COMMANDS = new Set(['npm', 'pnpm', 'yarn', 'npx']);
27
-
28
- function executableFor(manager: RepositoryManifest['packageManager']): string {
29
- if (manager === 'pnpm') return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
30
- if (manager === 'yarn') return process.platform === 'win32' ? 'yarn.cmd' : 'yarn';
31
- return process.platform === 'win32' ? 'npm.cmd' : 'npm';
32
- }
33
-
34
- function chooseScript(manifest: RepositoryManifest, requested?: string): string | undefined {
35
- if (requested) return requested;
36
- const candidates = ['test:e2e', 'test:smoke', 'test:sanity', 'test'];
37
- return candidates.find((candidate) => manifest.scripts[candidate]);
38
- }
39
-
40
- export class RepositoryRunner {
41
- public static async runBaseline(manifest: RepositoryManifest, options: RepositoryRunOptions = {}): Promise<RepositoryRunResult> {
42
- if (manifest.status !== 'DISCOVERED') {
43
- return {
44
- repository: manifest.name,
45
- command: '',
46
- args: [],
47
- cwd: manifest.root,
48
- status: 'BLOCKED',
49
- exitCode: null,
50
- stdout: '',
51
- stderr: '',
52
- durationMs: 0,
53
- reason: `Repository status is ${manifest.status}`,
54
- };
55
- }
56
-
57
- const script = chooseScript(manifest, options.script);
58
- const executable = executableFor(manifest.packageManager);
59
- const args = script ? ['run', script] : ['exec', 'playwright', 'test'];
60
- return this.runCommand(manifest, executable, args, options);
61
- }
62
-
63
- public static runCommand(
64
- manifest: RepositoryManifest,
65
- executable: string,
66
- args: string[],
67
- options: RepositoryRunOptions = {},
68
- ): Promise<RepositoryRunResult> {
69
- const normalizedExecutable = path.basename(executable).replace(/\.cmd$/i, '').toLowerCase();
70
- if (!SAFE_COMMANDS.has(normalizedExecutable)) {
71
- return Promise.resolve({
72
- repository: manifest.name,
73
- command: executable,
74
- args,
75
- cwd: manifest.root,
76
- status: 'BLOCKED',
77
- exitCode: null,
78
- stdout: '',
79
- stderr: '',
80
- durationMs: 0,
81
- reason: `Command '${executable}' is not in the repository evaluation allowlist`,
82
- });
83
- }
84
-
85
- if (args.some((argument) => /[&|<>]/.test(argument))) {
86
- return Promise.resolve({
87
- repository: manifest.name,
88
- command: executable,
89
- args,
90
- cwd: manifest.root,
91
- status: 'BLOCKED',
92
- exitCode: null,
93
- stdout: '',
94
- stderr: '',
95
- durationMs: 0,
96
- reason: 'Command arguments contain shell metacharacters',
97
- });
98
- }
99
-
100
- const isWindows = process.platform === 'win32';
101
- const command = isWindows ? (process.env.ComSpec ?? 'cmd.exe') : executable;
102
- const spawnArgs = isWindows
103
- ? ['/d', '/s', '/c', [executable, ...args].map((argument) => argument.includes(' ') ? `"${argument.replace(/"/g, '\\"')}"` : argument).join(' ')]
104
- : args;
105
-
106
- return new Promise((resolve) => {
107
- const started = Date.now();
108
- const child = spawn(command, spawnArgs, {
109
- cwd: manifest.root,
110
- shell: false,
111
- windowsHide: true,
112
- env: { ...process.env, CI: 'true', ...options.environment },
113
- });
114
- let stdout = '';
115
- let stderr = '';
116
- let truncated = false;
117
- let timedOut = false;
118
- let settled = false;
119
- let timeout: ReturnType<typeof setTimeout> | undefined;
120
- const finish = (result: RepositoryRunResult): void => {
121
- if (settled) return;
122
- settled = true;
123
- if (timeout) clearTimeout(timeout);
124
- resolve(result);
125
- };
126
- const append = (target: 'stdout' | 'stderr', chunk: Buffer): void => {
127
- if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) >= MAX_OUTPUT_BYTES) {
128
- truncated = true;
129
- return;
130
- }
131
- if (target === 'stdout') stdout += chunk.toString('utf8');
132
- else stderr += chunk.toString('utf8');
133
- };
134
- child.stdout?.on('data', (chunk: Buffer) => append('stdout', chunk));
135
- child.stderr?.on('data', (chunk: Buffer) => append('stderr', chunk));
136
-
137
- timeout = setTimeout(() => {
138
- timedOut = true;
139
- child.kill('SIGTERM');
140
- if (process.platform === 'win32' && child.pid) {
141
- spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
142
- windowsHide: true,
143
- stdio: 'ignore',
144
- shell: false,
145
- timeout: 5_000,
146
- });
147
- child.stdout?.destroy();
148
- child.stderr?.destroy();
149
- }
150
- finish({
151
- repository: manifest.name,
152
- command: executable,
153
- args,
154
- cwd: manifest.root,
155
- status: 'TIMEOUT',
156
- exitCode: null,
157
- stdout,
158
- stderr,
159
- durationMs: Date.now() - started,
160
- reason: 'Command exceeded the configured timeout',
161
- });
162
- }, options.timeoutMs ?? 15 * 60 * 1000);
163
-
164
- child.on('error', (error) => {
165
- finish({
166
- repository: manifest.name,
167
- command: executable,
168
- args,
169
- cwd: manifest.root,
170
- status: 'BLOCKED',
171
- exitCode: null,
172
- stdout,
173
- stderr,
174
- durationMs: Date.now() - started,
175
- reason: `Unable to start command: ${error.message}`,
176
- });
177
- });
178
-
179
- child.on('close', (exitCode, signal) => {
180
- finish({
181
- repository: manifest.name,
182
- command: executable,
183
- args,
184
- cwd: manifest.root,
185
- status: timedOut ? 'TIMEOUT' : exitCode === 0 ? 'PASS' : 'FAIL',
186
- exitCode,
187
- signal: signal ?? undefined,
188
- stdout,
189
- stderr,
190
- durationMs: Date.now() - started,
191
- reason: truncated ? 'Output truncated at 16 MB' : undefined,
192
- });
193
- });
194
- });
195
- }
196
- }
197
-
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { RepositoryManifest } from './repository-inspector.js';
4
+
5
+ export interface RepositoryRunOptions {
6
+ script?: string | undefined;
7
+ timeoutMs?: number | undefined;
8
+ environment?: Record<string, string> | undefined;
9
+ }
10
+
11
+ export interface RepositoryRunResult {
12
+ repository: string;
13
+ command: string;
14
+ args: string[];
15
+ cwd: string;
16
+ status: 'PASS' | 'FAIL' | 'TIMEOUT' | 'BLOCKED';
17
+ exitCode: number | null;
18
+ signal?: string | undefined;
19
+ stdout: string;
20
+ stderr: string;
21
+ durationMs: number;
22
+ reason?: string | undefined;
23
+ }
24
+
25
+ const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
26
+ const SAFE_COMMANDS = new Set(['npm', 'pnpm', 'yarn', 'npx']);
27
+
28
+ function executableFor(manager: RepositoryManifest['packageManager']): string {
29
+ if (manager === 'pnpm') return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
30
+ if (manager === 'yarn') return process.platform === 'win32' ? 'yarn.cmd' : 'yarn';
31
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
32
+ }
33
+
34
+ function chooseScript(manifest: RepositoryManifest, requested?: string): string | undefined {
35
+ if (requested) return requested;
36
+ const candidates = ['test:e2e', 'test:smoke', 'test:sanity', 'test'];
37
+ return candidates.find((candidate) => manifest.scripts[candidate]);
38
+ }
39
+
40
+ export class RepositoryRunner {
41
+ public static async runBaseline(manifest: RepositoryManifest, options: RepositoryRunOptions = {}): Promise<RepositoryRunResult> {
42
+ if (manifest.status !== 'DISCOVERED') {
43
+ return {
44
+ repository: manifest.name,
45
+ command: '',
46
+ args: [],
47
+ cwd: manifest.root,
48
+ status: 'BLOCKED',
49
+ exitCode: null,
50
+ stdout: '',
51
+ stderr: '',
52
+ durationMs: 0,
53
+ reason: `Repository status is ${manifest.status}`,
54
+ };
55
+ }
56
+
57
+ const script = chooseScript(manifest, options.script);
58
+ const executable = executableFor(manifest.packageManager);
59
+ const args = script ? ['run', script] : ['exec', 'playwright', 'test'];
60
+ return this.runCommand(manifest, executable, args, options);
61
+ }
62
+
63
+ public static runCommand(
64
+ manifest: RepositoryManifest,
65
+ executable: string,
66
+ args: string[],
67
+ options: RepositoryRunOptions = {},
68
+ ): Promise<RepositoryRunResult> {
69
+ const normalizedExecutable = path.basename(executable).replace(/\.cmd$/i, '').toLowerCase();
70
+ if (!SAFE_COMMANDS.has(normalizedExecutable)) {
71
+ return Promise.resolve({
72
+ repository: manifest.name,
73
+ command: executable,
74
+ args,
75
+ cwd: manifest.root,
76
+ status: 'BLOCKED',
77
+ exitCode: null,
78
+ stdout: '',
79
+ stderr: '',
80
+ durationMs: 0,
81
+ reason: `Command '${executable}' is not in the repository evaluation allowlist`,
82
+ });
83
+ }
84
+
85
+ if (args.some((argument) => /[&|<>]/.test(argument))) {
86
+ return Promise.resolve({
87
+ repository: manifest.name,
88
+ command: executable,
89
+ args,
90
+ cwd: manifest.root,
91
+ status: 'BLOCKED',
92
+ exitCode: null,
93
+ stdout: '',
94
+ stderr: '',
95
+ durationMs: 0,
96
+ reason: 'Command arguments contain shell metacharacters',
97
+ });
98
+ }
99
+
100
+ const isWindows = process.platform === 'win32';
101
+ const command = isWindows ? (process.env.ComSpec ?? 'cmd.exe') : executable;
102
+ const spawnArgs = isWindows
103
+ ? ['/d', '/s', '/c', [executable, ...args].map((argument) => argument.includes(' ') ? `"${argument.replace(/"/g, '\\"')}"` : argument).join(' ')]
104
+ : args;
105
+
106
+ return new Promise((resolve) => {
107
+ const started = Date.now();
108
+ const child = spawn(command, spawnArgs, {
109
+ cwd: manifest.root,
110
+ shell: false,
111
+ windowsHide: true,
112
+ env: { ...process.env, CI: 'true', ...options.environment },
113
+ });
114
+ let stdout = '';
115
+ let stderr = '';
116
+ let truncated = false;
117
+ let timedOut = false;
118
+ let settled = false;
119
+ let timeout: ReturnType<typeof setTimeout> | undefined;
120
+ const finish = (result: RepositoryRunResult): void => {
121
+ if (settled) return;
122
+ settled = true;
123
+ if (timeout) clearTimeout(timeout);
124
+ resolve(result);
125
+ };
126
+ const append = (target: 'stdout' | 'stderr', chunk: Buffer): void => {
127
+ if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) >= MAX_OUTPUT_BYTES) {
128
+ truncated = true;
129
+ return;
130
+ }
131
+ if (target === 'stdout') stdout += chunk.toString('utf8');
132
+ else stderr += chunk.toString('utf8');
133
+ };
134
+ child.stdout?.on('data', (chunk: Buffer) => append('stdout', chunk));
135
+ child.stderr?.on('data', (chunk: Buffer) => append('stderr', chunk));
136
+
137
+ timeout = setTimeout(() => {
138
+ timedOut = true;
139
+ child.kill('SIGTERM');
140
+ if (process.platform === 'win32' && child.pid) {
141
+ spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
142
+ windowsHide: true,
143
+ stdio: 'ignore',
144
+ shell: false,
145
+ timeout: 5_000,
146
+ });
147
+ child.stdout?.destroy();
148
+ child.stderr?.destroy();
149
+ }
150
+ finish({
151
+ repository: manifest.name,
152
+ command: executable,
153
+ args,
154
+ cwd: manifest.root,
155
+ status: 'TIMEOUT',
156
+ exitCode: null,
157
+ stdout,
158
+ stderr,
159
+ durationMs: Date.now() - started,
160
+ reason: 'Command exceeded the configured timeout',
161
+ });
162
+ }, options.timeoutMs ?? 15 * 60 * 1000);
163
+
164
+ child.on('error', (error) => {
165
+ finish({
166
+ repository: manifest.name,
167
+ command: executable,
168
+ args,
169
+ cwd: manifest.root,
170
+ status: 'BLOCKED',
171
+ exitCode: null,
172
+ stdout,
173
+ stderr,
174
+ durationMs: Date.now() - started,
175
+ reason: `Unable to start command: ${error.message}`,
176
+ });
177
+ });
178
+
179
+ child.on('close', (exitCode, signal) => {
180
+ finish({
181
+ repository: manifest.name,
182
+ command: executable,
183
+ args,
184
+ cwd: manifest.root,
185
+ status: timedOut ? 'TIMEOUT' : exitCode === 0 ? 'PASS' : 'FAIL',
186
+ exitCode,
187
+ signal: signal ?? undefined,
188
+ stdout,
189
+ stderr,
190
+ durationMs: Date.now() - started,
191
+ reason: truncated ? 'Output truncated at 16 MB' : undefined,
192
+ });
193
+ });
194
+ });
195
+ }
196
+ }
197
+
@@ -1,101 +1,101 @@
1
- import { NamingLinter, NamingLintReport } from '../authoring/naming/naming-linter.js';
2
- import type { NamePlan } from '../authoring/naming/name-plan.js';
3
-
4
- export interface CodeQualityGateInput {
5
- symbols?: NamePlan | undefined;
6
- files?: Array<{ relativePath: string; content: string }> | undefined;
7
- diff?: string | undefined;
8
- originalSource?: string | undefined;
9
- patchedSource?: string | undefined;
10
- locatorPolicy?: string | undefined;
11
- }
12
-
13
- export type QualityGateInput = CodeQualityGateInput;
14
-
15
- export interface QualityGateResult {
16
- passed: boolean;
17
- namingReport?: NamingLintReport | undefined;
18
- duplicateSelectors: string[];
19
- locatorPolicyViolations: string[];
20
- assertionStrengthPassed: boolean;
21
- changedLines: number;
22
- violations: string[];
23
- }
24
-
25
- export class QualityGate {
26
- public static evaluate(input: QualityGateInput): QualityGateResult {
27
- const violations: string[] = [];
28
-
29
- // 1. Naming Conformance
30
- let namingReport: NamingLintReport | undefined;
31
- if (input.symbols && input.symbols.symbols.length > 0) {
32
- namingReport = NamingLinter.lint(input.symbols);
33
- if (!namingReport.passed) {
34
- for (const v of namingReport.violations) {
35
- violations.push(`Naming quality violation [${v.kind}]: ${v.message}`);
36
- }
37
- }
38
- }
39
-
40
- // 2. Duplicate Selectors Check
41
- const duplicateSelectors: string[] = [];
42
- if (input.files) {
43
- for (const file of input.files) {
44
- if (file.relativePath.endsWith('Page.ts')) {
45
- const locatorMatches = [...file.content.matchAll(/this\.page\.(?:getByRole|getByText|getByTestId|locator)\s*\(([^)]+)\)/g)];
46
- const seen = new Set<string>();
47
- for (const match of locatorMatches) {
48
- const locStr = match[1]!.trim();
49
- if (seen.has(locStr)) {
50
- duplicateSelectors.push(locStr);
51
- violations.push(`Duplicate selector in '${file.relativePath}': ${locStr}`);
52
- }
53
- seen.add(locStr);
54
- }
55
- }
56
- }
57
- }
58
-
59
- // 3. Locator Policy Conformance (e.g. Reject raw CSS when ARIA is required)
60
- const locatorPolicyViolations: string[] = [];
61
- if (input.files && input.locatorPolicy === 'prefer-role-over-css') {
62
- for (const file of input.files) {
63
- const rawCssMatches = [...file.content.matchAll(/this\.page\.locator\s*\(\s*['"]([#.]|div|button|input)[^'"]*['"]\s*\)/g)];
64
- for (const match of rawCssMatches) {
65
- locatorPolicyViolations.push(match[0]);
66
- violations.push(`Locator policy violation in '${file.relativePath}': raw CSS used instead of role-based locator: ${match[0]}`);
67
- }
68
- }
69
- }
70
-
71
- // 4. Assertion Strength Non-Downgrade Check
72
- let assertionStrengthPassed = true;
73
- if (input.originalSource && input.patchedSource) {
74
- const origHasExactText = /\b(toHaveText|toHaveValue|toContainText)\b/.test(input.originalSource);
75
- const patchHasExactText = /\b(toHaveText|toHaveValue|toContainText)\b/.test(input.patchedSource);
76
- const patchDowngradedToVisible = /\btoBeVisible\b/.test(input.patchedSource) && !/\btoBeVisible\b/.test(input.originalSource);
77
-
78
- if (origHasExactText && !patchHasExactText && patchDowngradedToVisible) {
79
- assertionStrengthPassed = false;
80
- violations.push('Assertion strength regression: exact text assertion was downgraded to toBeVisible');
81
- }
82
- }
83
-
84
- // 5. Diff Minimality (Count changed lines)
85
- let changedLines = 0;
86
- if (input.diff) {
87
- const diffLines = input.diff.split(/\r?\n/);
88
- changedLines = diffLines.filter((l) => (l.startsWith('+') || l.startsWith('-')) && !l.startsWith('+++') && !l.startsWith('---')).length;
89
- }
90
-
91
- return {
92
- passed: violations.length === 0,
93
- namingReport,
94
- duplicateSelectors,
95
- locatorPolicyViolations,
96
- assertionStrengthPassed,
97
- changedLines,
98
- violations,
99
- };
100
- }
101
- }
1
+ import { NamingLinter, NamingLintReport } from '../authoring/naming/naming-linter.js';
2
+ import type { NamePlan } from '../authoring/naming/name-plan.js';
3
+
4
+ export interface CodeQualityGateInput {
5
+ symbols?: NamePlan | undefined;
6
+ files?: Array<{ relativePath: string; content: string }> | undefined;
7
+ diff?: string | undefined;
8
+ originalSource?: string | undefined;
9
+ patchedSource?: string | undefined;
10
+ locatorPolicy?: string | undefined;
11
+ }
12
+
13
+ export type QualityGateInput = CodeQualityGateInput;
14
+
15
+ export interface QualityGateResult {
16
+ passed: boolean;
17
+ namingReport?: NamingLintReport | undefined;
18
+ duplicateSelectors: string[];
19
+ locatorPolicyViolations: string[];
20
+ assertionStrengthPassed: boolean;
21
+ changedLines: number;
22
+ violations: string[];
23
+ }
24
+
25
+ export class QualityGate {
26
+ public static evaluate(input: QualityGateInput): QualityGateResult {
27
+ const violations: string[] = [];
28
+
29
+ // 1. Naming Conformance
30
+ let namingReport: NamingLintReport | undefined;
31
+ if (input.symbols && input.symbols.symbols.length > 0) {
32
+ namingReport = NamingLinter.lint(input.symbols);
33
+ if (!namingReport.passed) {
34
+ for (const v of namingReport.violations) {
35
+ violations.push(`Naming quality violation [${v.kind}]: ${v.message}`);
36
+ }
37
+ }
38
+ }
39
+
40
+ // 2. Duplicate Selectors Check
41
+ const duplicateSelectors: string[] = [];
42
+ if (input.files) {
43
+ for (const file of input.files) {
44
+ if (file.relativePath.endsWith('Page.ts')) {
45
+ const locatorMatches = [...file.content.matchAll(/this\.page\.(?:getByRole|getByText|getByTestId|locator)\s*\(([^)]+)\)/g)];
46
+ const seen = new Set<string>();
47
+ for (const match of locatorMatches) {
48
+ const locStr = match[1]!.trim();
49
+ if (seen.has(locStr)) {
50
+ duplicateSelectors.push(locStr);
51
+ violations.push(`Duplicate selector in '${file.relativePath}': ${locStr}`);
52
+ }
53
+ seen.add(locStr);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ // 3. Locator Policy Conformance (e.g. Reject raw CSS when ARIA is required)
60
+ const locatorPolicyViolations: string[] = [];
61
+ if (input.files && input.locatorPolicy === 'prefer-role-over-css') {
62
+ for (const file of input.files) {
63
+ const rawCssMatches = [...file.content.matchAll(/this\.page\.locator\s*\(\s*['"]([#.]|div|button|input)[^'"]*['"]\s*\)/g)];
64
+ for (const match of rawCssMatches) {
65
+ locatorPolicyViolations.push(match[0]);
66
+ violations.push(`Locator policy violation in '${file.relativePath}': raw CSS used instead of role-based locator: ${match[0]}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ // 4. Assertion Strength Non-Downgrade Check
72
+ let assertionStrengthPassed = true;
73
+ if (input.originalSource && input.patchedSource) {
74
+ const origHasExactText = /\b(toHaveText|toHaveValue|toContainText)\b/.test(input.originalSource);
75
+ const patchHasExactText = /\b(toHaveText|toHaveValue|toContainText)\b/.test(input.patchedSource);
76
+ const patchDowngradedToVisible = /\btoBeVisible\b/.test(input.patchedSource) && !/\btoBeVisible\b/.test(input.originalSource);
77
+
78
+ if (origHasExactText && !patchHasExactText && patchDowngradedToVisible) {
79
+ assertionStrengthPassed = false;
80
+ violations.push('Assertion strength regression: exact text assertion was downgraded to toBeVisible');
81
+ }
82
+ }
83
+
84
+ // 5. Diff Minimality (Count changed lines)
85
+ let changedLines = 0;
86
+ if (input.diff) {
87
+ const diffLines = input.diff.split(/\r?\n/);
88
+ changedLines = diffLines.filter((l) => (l.startsWith('+') || l.startsWith('-')) && !l.startsWith('+++') && !l.startsWith('---')).length;
89
+ }
90
+
91
+ return {
92
+ passed: violations.length === 0,
93
+ namingReport,
94
+ duplicateSelectors,
95
+ locatorPolicyViolations,
96
+ assertionStrengthPassed,
97
+ changedLines,
98
+ violations,
99
+ };
100
+ }
101
+ }