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.
- package/README.md +229 -229
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/core/version.d.ts +3 -3
- package/dist/index.js +81 -24
- package/package.json +76 -76
- package/src/authoring/author.ts +81 -81
- package/src/authoring/generation-contract.ts +283 -283
- package/src/authoring/hybrid-author.ts +115 -115
- package/src/authoring/index.ts +7 -7
- package/src/authoring/llm-author.ts +282 -282
- package/src/authoring/naming/name-plan.ts +28 -28
- package/src/authoring/naming/name-planner.ts +339 -339
- package/src/authoring/naming/naming-linter.ts +292 -292
- package/src/cli/index.ts +1238 -1172
- package/src/collectors/git-diff-collector.ts +190 -190
- package/src/core/version.ts +25 -25
- package/src/diagnostics/diagnostic-report.ts +204 -204
- package/src/diagnostics/failure-classifier.ts +701 -701
- package/src/evaluation/repository-runner.ts +197 -197
- package/src/gate/quality.ts +101 -101
- package/src/generator/architecture-generator.ts +582 -582
- package/src/generator/assertion-quality-gate.ts +88 -88
- package/src/generator/candidate-writer.ts +288 -288
- package/src/generator/test-generator.ts +52 -52
- package/src/index.ts +107 -107
- package/src/intelligence/evidence-bundle.ts +300 -300
- package/src/intelligence/generation-agent.ts +561 -561
- package/src/intelligence/generation-verifier.ts +1115 -1115
- package/src/intelligence/locator-disambiguation-engine.ts +331 -331
- package/src/intelligence/project-intelligence.ts +140 -140
- package/src/intelligence/repository-profile.ts +796 -796
- package/src/intelligence/trace-proposal-workflow.ts +318 -318
- package/src/intelligence/workflow-planner.ts +966 -966
- package/src/mcp/server.ts +970 -970
- package/src/perception/app-model.ts +32 -32
- package/src/perception/crawler.ts +263 -263
- package/src/perception/index.ts +3 -3
- package/src/repair/assertion-hasher.ts +386 -386
- package/src/repair/diff-allowlist.ts +612 -612
- package/src/repair/intent-validator.ts +761 -761
- package/src/repair/repair-planner.ts +467 -467
- package/src/repair/statement-patcher.ts +544 -544
- package/src/reproduce/interactive-workflow-runner.ts +828 -828
- package/src/sandbox/execution-runner.ts +408 -408
- package/src/selector/abstention-policy.ts +175 -175
- package/src/selector/candidate-extractor.ts +343 -343
- package/src/selector/repair-candidate-ranker.ts +92 -92
- 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
|
+
}
|