playwright-intelligence-engine 2.0.0 → 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,292 +1,292 @@
1
- import type { NamePlan } from './name-plan.js';
2
-
3
- export interface NamingLintViolation {
4
- kind:
5
- | 'INDEX_DERIVED_IDENTIFIER'
6
- | 'DUPLICATE_PROPERTY'
7
- | 'DUPLICATE_METHOD'
8
- | 'DUPLICATE_WORD_IN_IDENTIFIER'
9
- | 'CLASS_NAME_CASING'
10
- | 'RESERVED_WORD_COLLISION'
11
- | 'MISSING_VERB'
12
- | 'ZERO_SEMANTIC_OVERLAP'
13
- | 'UNGROUNDED_IDENTIFIER';
14
- message: string;
15
- identifier: string;
16
- ref?: string | undefined;
17
- }
18
-
19
- export interface NamingLintReport {
20
- passed: boolean;
21
- violations: NamingLintViolation[];
22
- totalIdentifiers: number;
23
- indexDerivedIdentifierCount: number;
24
- duplicateSymbolCount: number;
25
- semanticNameCoverage: number | null;
26
- namingConformanceRate: number | null;
27
- }
28
-
29
- const RESERVED_WORDS = new Set([
30
- 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
31
- 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof',
32
- 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while',
33
- 'with', 'yield', 'enum', 'implements', 'interface', 'let', 'package', 'private', 'protected',
34
- 'public', 'static', 'await', 'abstract', 'boolean', 'byte', 'char', 'double', 'final', 'float',
35
- 'goto', 'int', 'long', 'native', 'short', 'synchronized', 'throws', 'transient', 'volatile',
36
- 'page', 'constructor', 'locator',
37
- ]);
38
-
39
- const ALLOWED_VERBS = new Set([
40
- 'fill', 'enter', 'click', 'submit', 'signIn', 'signUp', 'open', 'goTo', 'select', 'check',
41
- 'uncheck', 'expectVisible', 'expectText', 'expectHidden', 'perform', 'press', 'hover', 'clear',
42
- 'add', 'create', 'update', 'edit', 'delete', 'remove', 'toggle', 'filter', 'search', 'navigate',
43
- 'login', 'logout', 'register', 'verify', 'assert', 'wait', 'proceed', 'confirm', 'cancel',
44
- 'complete', 'reset', 'sort', 'apply', 'upload', 'download', 'type', 'focus', 'get', 'is', 'has',
45
- ]);
46
-
47
- /** Generic role-derived identifiers with no accessible name (actionButton, buttonButton). */
48
- const GENERIC_ROLE_NAME_RE = /^(action|button|input|element|field|link|text|heading|checkbox|combobox|radio|select|tab|cell|status|dialog|content|widget)(Button|Input|Element|Field|Link|Text|Heading|Checkbox|Combobox|Radio|Select|Tab|Cell|Status|Dialog|Content|Widget)?$/i;
49
-
50
- const SEMANTIC_SYNONYMS: Record<string, string[]> = {
51
- register: ['signup', 'sign', 'up', 'register', 'enroll'],
52
- signup: ['register', 'sign', 'up', 'signup', 'enroll'],
53
- login: ['signin', 'sign', 'in', 'login', 'auth'],
54
- signin: ['login', 'sign', 'in', 'signin', 'auth'],
55
- checkout: ['order', 'buy', 'purchase', 'submit', 'pay'],
56
- save: ['submit', 'confirm', 'apply'],
57
- apply: ['submit', 'save', 'application'],
58
- };
59
-
60
- function extractWords(identifier: string): string[] {
61
- const tokens = identifier
62
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
63
- .replace(/[^a-zA-Z0-9]+/g, ' ')
64
- .trim()
65
- .split(/\s+/)
66
- .filter(Boolean);
67
-
68
- const words: string[] = [];
69
- for (const token of tokens) {
70
- if (token.length >= 6 && token.length % 2 === 0) {
71
- const half = token.slice(0, token.length / 2);
72
- const secondHalf = token.slice(token.length / 2);
73
- if (half.toLowerCase() === secondHalf.toLowerCase()) {
74
- words.push(half, secondHalf);
75
- continue;
76
- }
77
- }
78
- words.push(token);
79
- }
80
- return words;
81
- }
82
-
83
- function hasDuplicateAdjacentWords(identifier: string): boolean {
84
- const words = extractWords(identifier);
85
- if (words.length < 2) return false;
86
- for (let i = 0; i < words.length - 1; i++) {
87
- if (words[i]!.toLowerCase() === words[i + 1]!.toLowerCase()) {
88
- return true;
89
- }
90
- }
91
- return false;
92
- }
93
-
94
- export class NamingLinter {
95
- public static lint(plan: NamePlan): NamingLintReport {
96
- const violations: NamingLintViolation[] = [];
97
- const seenProperties = new Set<string>();
98
- const seenMethods = new Set<string>();
99
-
100
- let indexDerivedCount = 0;
101
- let duplicateCount = 0;
102
- let semanticMatches = 0;
103
- let totalAssessed = 0;
104
-
105
- // Check class name casing
106
- if (plan.pageClassName) {
107
- totalAssessed++;
108
- if (!/^[A-Z][a-zA-Z0-9]*Page$/.test(plan.pageClassName)) {
109
- violations.push({
110
- kind: 'CLASS_NAME_CASING',
111
- message: `Class name '${plan.pageClassName}' must be PascalCase and end with 'Page'`,
112
- identifier: plan.pageClassName,
113
- });
114
- }
115
-
116
- // Detect flattened casing (e.g. OrderreviewPage vs OrderReviewPage)
117
- const base = plan.pageClassName.replace(/Page$/, '');
118
- const words = extractWords(base);
119
- if (words.length === 1 && words[0]!.length > 8 && /^[A-Z][a-z]+$/.test(words[0]!)) {
120
- // Known compound keywords that should not be flattened
121
- const commonCompounds = ['orderreview', 'userlogin', 'customersignup', 'productsearch', 'shoppingcart'];
122
- if (commonCompounds.some((c) => words[0]!.toLowerCase().includes(c))) {
123
- violations.push({
124
- kind: 'CLASS_NAME_CASING',
125
- message: `Class name '${plan.pageClassName}' exhibits flattened compound casing`,
126
- identifier: plan.pageClassName,
127
- });
128
- }
129
- }
130
- }
131
-
132
- for (let stepIndex = 0; stepIndex < plan.symbols.length; stepIndex++) {
133
- const sym = plan.symbols[stepIndex]!;
134
- const expectedNum = stepIndex + 1;
135
-
136
- // 1. Check property
137
- totalAssessed++;
138
-
139
- // Check positional correlation for index derivation
140
- const propDigitMatch = sym.property.match(/\d+$/);
141
- const isPropPositionalIndex = propDigitMatch && (parseInt(propDigitMatch[0], 10) === expectedNum || parseInt(propDigitMatch[0], 10) === stepIndex);
142
- const isPropGenericIndexed = /^(actionButton|inputField|button|input|action|field|element)\d+$/i.test(sym.property);
143
-
144
- if (isPropPositionalIndex || isPropGenericIndexed) {
145
- indexDerivedCount++;
146
- violations.push({
147
- kind: 'INDEX_DERIVED_IDENTIFIER',
148
- message: `Property '${sym.property}' is derived from a raw index (matched step ${expectedNum})`,
149
- identifier: sym.property,
150
- ref: sym.ref,
151
- });
152
- }
153
-
154
- // Check duplicate adjacent words
155
- if (hasDuplicateAdjacentWords(sym.property)) {
156
- violations.push({
157
- kind: 'DUPLICATE_WORD_IN_IDENTIFIER',
158
- message: `Property '${sym.property}' contains duplicated adjacent word`,
159
- identifier: sym.property,
160
- ref: sym.ref,
161
- });
162
- }
163
-
164
- if (seenProperties.has(sym.property)) {
165
- duplicateCount++;
166
- violations.push({
167
- kind: 'DUPLICATE_PROPERTY',
168
- message: `Duplicate property '${sym.property}' declared in Page Object`,
169
- identifier: sym.property,
170
- ref: sym.ref,
171
- });
172
- }
173
- seenProperties.add(sym.property);
174
-
175
- if (RESERVED_WORDS.has(sym.property.toLowerCase())) {
176
- violations.push({
177
- kind: 'RESERVED_WORD_COLLISION',
178
- message: `Property '${sym.property}' collides with reserved language keyword`,
179
- identifier: sym.property,
180
- ref: sym.ref,
181
- });
182
- }
183
-
184
- // 2. Check method
185
- totalAssessed++;
186
-
187
- const methodDigitMatch = sym.method.match(/\d+$/);
188
- const isMethodPositionalIndex = methodDigitMatch && (parseInt(methodDigitMatch[0], 10) === expectedNum || parseInt(methodDigitMatch[0], 10) === stepIndex);
189
- const isMethodGenericIndexed = /^(click|fill|action|perform|open)\d+$/i.test(sym.method);
190
-
191
- if (isMethodPositionalIndex || isMethodGenericIndexed) {
192
- indexDerivedCount++;
193
- violations.push({
194
- kind: 'INDEX_DERIVED_IDENTIFIER',
195
- message: `Method '${sym.method}' is derived from a raw index (matched step ${expectedNum})`,
196
- identifier: sym.method,
197
- ref: sym.ref,
198
- });
199
- }
200
-
201
- // Check duplicate adjacent words
202
- if (hasDuplicateAdjacentWords(sym.method)) {
203
- violations.push({
204
- kind: 'DUPLICATE_WORD_IN_IDENTIFIER',
205
- message: `Method '${sym.method}' contains duplicated adjacent word`,
206
- identifier: sym.method,
207
- ref: sym.ref,
208
- });
209
- }
210
-
211
- if (seenMethods.has(sym.method)) {
212
- duplicateCount++;
213
- violations.push({
214
- kind: 'DUPLICATE_METHOD',
215
- message: `Duplicate method '${sym.method}' declared in Page Object`,
216
- identifier: sym.method,
217
- ref: sym.ref,
218
- });
219
- }
220
- seenMethods.add(sym.method);
221
-
222
- if (RESERVED_WORDS.has(sym.method.toLowerCase())) {
223
- violations.push({
224
- kind: 'RESERVED_WORD_COLLISION',
225
- message: `Method '${sym.method}' collides with reserved language keyword`,
226
- identifier: sym.method,
227
- ref: sym.ref,
228
- });
229
- }
230
-
231
- if (!ALLOWED_VERBS.has(sym.verb)) {
232
- violations.push({
233
- kind: 'MISSING_VERB',
234
- message: `Method '${sym.method}' has unapproved verb '${sym.verb}'`,
235
- identifier: sym.method,
236
- ref: sym.ref,
237
- });
238
- }
239
-
240
- // 3. Check semantic overlap with accessibleName (evaluating BOTH property and method)
241
- if (sym.accessibleName && sym.accessibleName.trim().length > 0) {
242
- const tokens = sym.accessibleName.toLowerCase().match(/[a-z0-9]+/g) ?? [];
243
- const propLower = sym.property.toLowerCase();
244
- const methodLower = sym.method.toLowerCase();
245
- const meaningfulTokens = tokens.filter((t) => t.length > 2 && !['the', 'and', 'for', 'with'].includes(t));
246
- const candidateTerms = [
247
- ...meaningfulTokens,
248
- ...meaningfulTokens.flatMap((t) => SEMANTIC_SYNONYMS[t] ?? []),
249
- ];
250
-
251
- const propOverlap = candidateTerms.some((t) => propLower.includes(t));
252
- const methodOverlap = candidateTerms.some((t) => methodLower.includes(t));
253
-
254
- if (propOverlap && methodOverlap) {
255
- semanticMatches++;
256
- } else {
257
- // Zero semantic overlap violation: applies to both heuristic and LLM sources
258
- violations.push({
259
- kind: 'ZERO_SEMANTIC_OVERLAP',
260
- message: `Symbol '${sym.property}'/'${sym.method}' shares insufficient semantic tokens with accessible name '${sym.accessibleName}'`,
261
- identifier: `${sym.property} / ${sym.method}`,
262
- ref: sym.ref,
263
- });
264
- }
265
- } else if (GENERIC_ROLE_NAME_RE.test(sym.property)) {
266
- violations.push({
267
- kind: 'UNGROUNDED_IDENTIFIER',
268
- message: `Property '${sym.property}' is a generic role-derived name with no accessible name`,
269
- identifier: sym.property,
270
- ref: sym.ref,
271
- });
272
- }
273
- // Note: If no accessibleName is provided, semanticMatches is NOT incremented (absent evidence is not covered)
274
- }
275
-
276
- const totalSymbols = plan.symbols.length;
277
- // Empty denominator rule: n === 0 -> null (never 1.0 or 100%)
278
- const semanticNameCoverage = totalSymbols > 0 ? Number((semanticMatches / totalSymbols).toFixed(4)) : null;
279
- const nonViolating = Math.max(0, totalAssessed - violations.length);
280
- const namingConformanceRate = totalAssessed > 0 ? Number((nonViolating / totalAssessed).toFixed(4)) : null;
281
-
282
- return {
283
- passed: violations.length === 0 && totalAssessed > 0 && totalSymbols > 0,
284
- violations,
285
- totalIdentifiers: totalAssessed,
286
- indexDerivedIdentifierCount: indexDerivedCount,
287
- duplicateSymbolCount: duplicateCount,
288
- semanticNameCoverage,
289
- namingConformanceRate,
290
- };
291
- }
292
- }
1
+ import type { NamePlan } from './name-plan.js';
2
+
3
+ export interface NamingLintViolation {
4
+ kind:
5
+ | 'INDEX_DERIVED_IDENTIFIER'
6
+ | 'DUPLICATE_PROPERTY'
7
+ | 'DUPLICATE_METHOD'
8
+ | 'DUPLICATE_WORD_IN_IDENTIFIER'
9
+ | 'CLASS_NAME_CASING'
10
+ | 'RESERVED_WORD_COLLISION'
11
+ | 'MISSING_VERB'
12
+ | 'ZERO_SEMANTIC_OVERLAP'
13
+ | 'UNGROUNDED_IDENTIFIER';
14
+ message: string;
15
+ identifier: string;
16
+ ref?: string | undefined;
17
+ }
18
+
19
+ export interface NamingLintReport {
20
+ passed: boolean;
21
+ violations: NamingLintViolation[];
22
+ totalIdentifiers: number;
23
+ indexDerivedIdentifierCount: number;
24
+ duplicateSymbolCount: number;
25
+ semanticNameCoverage: number | null;
26
+ namingConformanceRate: number | null;
27
+ }
28
+
29
+ const RESERVED_WORDS = new Set([
30
+ 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
31
+ 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof',
32
+ 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while',
33
+ 'with', 'yield', 'enum', 'implements', 'interface', 'let', 'package', 'private', 'protected',
34
+ 'public', 'static', 'await', 'abstract', 'boolean', 'byte', 'char', 'double', 'final', 'float',
35
+ 'goto', 'int', 'long', 'native', 'short', 'synchronized', 'throws', 'transient', 'volatile',
36
+ 'page', 'constructor', 'locator',
37
+ ]);
38
+
39
+ const ALLOWED_VERBS = new Set([
40
+ 'fill', 'enter', 'click', 'submit', 'signIn', 'signUp', 'open', 'goTo', 'select', 'check',
41
+ 'uncheck', 'expectVisible', 'expectText', 'expectHidden', 'perform', 'press', 'hover', 'clear',
42
+ 'add', 'create', 'update', 'edit', 'delete', 'remove', 'toggle', 'filter', 'search', 'navigate',
43
+ 'login', 'logout', 'register', 'verify', 'assert', 'wait', 'proceed', 'confirm', 'cancel',
44
+ 'complete', 'reset', 'sort', 'apply', 'upload', 'download', 'type', 'focus', 'get', 'is', 'has',
45
+ ]);
46
+
47
+ /** Generic role-derived identifiers with no accessible name (actionButton, buttonButton). */
48
+ const GENERIC_ROLE_NAME_RE = /^(action|button|input|element|field|link|text|heading|checkbox|combobox|radio|select|tab|cell|status|dialog|content|widget)(Button|Input|Element|Field|Link|Text|Heading|Checkbox|Combobox|Radio|Select|Tab|Cell|Status|Dialog|Content|Widget)?$/i;
49
+
50
+ const SEMANTIC_SYNONYMS: Record<string, string[]> = {
51
+ register: ['signup', 'sign', 'up', 'register', 'enroll'],
52
+ signup: ['register', 'sign', 'up', 'signup', 'enroll'],
53
+ login: ['signin', 'sign', 'in', 'login', 'auth'],
54
+ signin: ['login', 'sign', 'in', 'signin', 'auth'],
55
+ checkout: ['order', 'buy', 'purchase', 'submit', 'pay'],
56
+ save: ['submit', 'confirm', 'apply'],
57
+ apply: ['submit', 'save', 'application'],
58
+ };
59
+
60
+ function extractWords(identifier: string): string[] {
61
+ const tokens = identifier
62
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
63
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
64
+ .trim()
65
+ .split(/\s+/)
66
+ .filter(Boolean);
67
+
68
+ const words: string[] = [];
69
+ for (const token of tokens) {
70
+ if (token.length >= 6 && token.length % 2 === 0) {
71
+ const half = token.slice(0, token.length / 2);
72
+ const secondHalf = token.slice(token.length / 2);
73
+ if (half.toLowerCase() === secondHalf.toLowerCase()) {
74
+ words.push(half, secondHalf);
75
+ continue;
76
+ }
77
+ }
78
+ words.push(token);
79
+ }
80
+ return words;
81
+ }
82
+
83
+ function hasDuplicateAdjacentWords(identifier: string): boolean {
84
+ const words = extractWords(identifier);
85
+ if (words.length < 2) return false;
86
+ for (let i = 0; i < words.length - 1; i++) {
87
+ if (words[i]!.toLowerCase() === words[i + 1]!.toLowerCase()) {
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ export class NamingLinter {
95
+ public static lint(plan: NamePlan): NamingLintReport {
96
+ const violations: NamingLintViolation[] = [];
97
+ const seenProperties = new Set<string>();
98
+ const seenMethods = new Set<string>();
99
+
100
+ let indexDerivedCount = 0;
101
+ let duplicateCount = 0;
102
+ let semanticMatches = 0;
103
+ let totalAssessed = 0;
104
+
105
+ // Check class name casing
106
+ if (plan.pageClassName) {
107
+ totalAssessed++;
108
+ if (!/^[A-Z][a-zA-Z0-9]*Page$/.test(plan.pageClassName)) {
109
+ violations.push({
110
+ kind: 'CLASS_NAME_CASING',
111
+ message: `Class name '${plan.pageClassName}' must be PascalCase and end with 'Page'`,
112
+ identifier: plan.pageClassName,
113
+ });
114
+ }
115
+
116
+ // Detect flattened casing (e.g. OrderreviewPage vs OrderReviewPage)
117
+ const base = plan.pageClassName.replace(/Page$/, '');
118
+ const words = extractWords(base);
119
+ if (words.length === 1 && words[0]!.length > 8 && /^[A-Z][a-z]+$/.test(words[0]!)) {
120
+ // Known compound keywords that should not be flattened
121
+ const commonCompounds = ['orderreview', 'userlogin', 'customersignup', 'productsearch', 'shoppingcart'];
122
+ if (commonCompounds.some((c) => words[0]!.toLowerCase().includes(c))) {
123
+ violations.push({
124
+ kind: 'CLASS_NAME_CASING',
125
+ message: `Class name '${plan.pageClassName}' exhibits flattened compound casing`,
126
+ identifier: plan.pageClassName,
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ for (let stepIndex = 0; stepIndex < plan.symbols.length; stepIndex++) {
133
+ const sym = plan.symbols[stepIndex]!;
134
+ const expectedNum = stepIndex + 1;
135
+
136
+ // 1. Check property
137
+ totalAssessed++;
138
+
139
+ // Check positional correlation for index derivation
140
+ const propDigitMatch = sym.property.match(/\d+$/);
141
+ const isPropPositionalIndex = propDigitMatch && (parseInt(propDigitMatch[0], 10) === expectedNum || parseInt(propDigitMatch[0], 10) === stepIndex);
142
+ const isPropGenericIndexed = /^(actionButton|inputField|button|input|action|field|element)\d+$/i.test(sym.property);
143
+
144
+ if (isPropPositionalIndex || isPropGenericIndexed) {
145
+ indexDerivedCount++;
146
+ violations.push({
147
+ kind: 'INDEX_DERIVED_IDENTIFIER',
148
+ message: `Property '${sym.property}' is derived from a raw index (matched step ${expectedNum})`,
149
+ identifier: sym.property,
150
+ ref: sym.ref,
151
+ });
152
+ }
153
+
154
+ // Check duplicate adjacent words
155
+ if (hasDuplicateAdjacentWords(sym.property)) {
156
+ violations.push({
157
+ kind: 'DUPLICATE_WORD_IN_IDENTIFIER',
158
+ message: `Property '${sym.property}' contains duplicated adjacent word`,
159
+ identifier: sym.property,
160
+ ref: sym.ref,
161
+ });
162
+ }
163
+
164
+ if (seenProperties.has(sym.property)) {
165
+ duplicateCount++;
166
+ violations.push({
167
+ kind: 'DUPLICATE_PROPERTY',
168
+ message: `Duplicate property '${sym.property}' declared in Page Object`,
169
+ identifier: sym.property,
170
+ ref: sym.ref,
171
+ });
172
+ }
173
+ seenProperties.add(sym.property);
174
+
175
+ if (RESERVED_WORDS.has(sym.property.toLowerCase())) {
176
+ violations.push({
177
+ kind: 'RESERVED_WORD_COLLISION',
178
+ message: `Property '${sym.property}' collides with reserved language keyword`,
179
+ identifier: sym.property,
180
+ ref: sym.ref,
181
+ });
182
+ }
183
+
184
+ // 2. Check method
185
+ totalAssessed++;
186
+
187
+ const methodDigitMatch = sym.method.match(/\d+$/);
188
+ const isMethodPositionalIndex = methodDigitMatch && (parseInt(methodDigitMatch[0], 10) === expectedNum || parseInt(methodDigitMatch[0], 10) === stepIndex);
189
+ const isMethodGenericIndexed = /^(click|fill|action|perform|open)\d+$/i.test(sym.method);
190
+
191
+ if (isMethodPositionalIndex || isMethodGenericIndexed) {
192
+ indexDerivedCount++;
193
+ violations.push({
194
+ kind: 'INDEX_DERIVED_IDENTIFIER',
195
+ message: `Method '${sym.method}' is derived from a raw index (matched step ${expectedNum})`,
196
+ identifier: sym.method,
197
+ ref: sym.ref,
198
+ });
199
+ }
200
+
201
+ // Check duplicate adjacent words
202
+ if (hasDuplicateAdjacentWords(sym.method)) {
203
+ violations.push({
204
+ kind: 'DUPLICATE_WORD_IN_IDENTIFIER',
205
+ message: `Method '${sym.method}' contains duplicated adjacent word`,
206
+ identifier: sym.method,
207
+ ref: sym.ref,
208
+ });
209
+ }
210
+
211
+ if (seenMethods.has(sym.method)) {
212
+ duplicateCount++;
213
+ violations.push({
214
+ kind: 'DUPLICATE_METHOD',
215
+ message: `Duplicate method '${sym.method}' declared in Page Object`,
216
+ identifier: sym.method,
217
+ ref: sym.ref,
218
+ });
219
+ }
220
+ seenMethods.add(sym.method);
221
+
222
+ if (RESERVED_WORDS.has(sym.method.toLowerCase())) {
223
+ violations.push({
224
+ kind: 'RESERVED_WORD_COLLISION',
225
+ message: `Method '${sym.method}' collides with reserved language keyword`,
226
+ identifier: sym.method,
227
+ ref: sym.ref,
228
+ });
229
+ }
230
+
231
+ if (!ALLOWED_VERBS.has(sym.verb)) {
232
+ violations.push({
233
+ kind: 'MISSING_VERB',
234
+ message: `Method '${sym.method}' has unapproved verb '${sym.verb}'`,
235
+ identifier: sym.method,
236
+ ref: sym.ref,
237
+ });
238
+ }
239
+
240
+ // 3. Check semantic overlap with accessibleName (evaluating BOTH property and method)
241
+ if (sym.accessibleName && sym.accessibleName.trim().length > 0) {
242
+ const tokens = sym.accessibleName.toLowerCase().match(/[a-z0-9]+/g) ?? [];
243
+ const propLower = sym.property.toLowerCase();
244
+ const methodLower = sym.method.toLowerCase();
245
+ const meaningfulTokens = tokens.filter((t) => t.length > 2 && !['the', 'and', 'for', 'with'].includes(t));
246
+ const candidateTerms = [
247
+ ...meaningfulTokens,
248
+ ...meaningfulTokens.flatMap((t) => SEMANTIC_SYNONYMS[t] ?? []),
249
+ ];
250
+
251
+ const propOverlap = candidateTerms.some((t) => propLower.includes(t));
252
+ const methodOverlap = candidateTerms.some((t) => methodLower.includes(t));
253
+
254
+ if (propOverlap && methodOverlap) {
255
+ semanticMatches++;
256
+ } else {
257
+ // Zero semantic overlap violation: applies to both heuristic and LLM sources
258
+ violations.push({
259
+ kind: 'ZERO_SEMANTIC_OVERLAP',
260
+ message: `Symbol '${sym.property}'/'${sym.method}' shares insufficient semantic tokens with accessible name '${sym.accessibleName}'`,
261
+ identifier: `${sym.property} / ${sym.method}`,
262
+ ref: sym.ref,
263
+ });
264
+ }
265
+ } else if (GENERIC_ROLE_NAME_RE.test(sym.property)) {
266
+ violations.push({
267
+ kind: 'UNGROUNDED_IDENTIFIER',
268
+ message: `Property '${sym.property}' is a generic role-derived name with no accessible name`,
269
+ identifier: sym.property,
270
+ ref: sym.ref,
271
+ });
272
+ }
273
+ // Note: If no accessibleName is provided, semanticMatches is NOT incremented (absent evidence is not covered)
274
+ }
275
+
276
+ const totalSymbols = plan.symbols.length;
277
+ // Empty denominator rule: n === 0 -> null (never 1.0 or 100%)
278
+ const semanticNameCoverage = totalSymbols > 0 ? Number((semanticMatches / totalSymbols).toFixed(4)) : null;
279
+ const nonViolating = Math.max(0, totalAssessed - violations.length);
280
+ const namingConformanceRate = totalAssessed > 0 ? Number((nonViolating / totalAssessed).toFixed(4)) : null;
281
+
282
+ return {
283
+ passed: violations.length === 0 && totalAssessed > 0 && totalSymbols > 0,
284
+ violations,
285
+ totalIdentifiers: totalAssessed,
286
+ indexDerivedIdentifierCount: indexDerivedCount,
287
+ duplicateSymbolCount: duplicateCount,
288
+ semanticNameCoverage,
289
+ namingConformanceRate,
290
+ };
291
+ }
292
+ }