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,344 +1,344 @@
|
|
|
1
|
-
import { ResolvedElement } from '../trace/snapshot-resolver.js';
|
|
2
|
-
|
|
3
|
-
export interface ScorerWeights {
|
|
4
|
-
wU: number; // Uniqueness
|
|
5
|
-
wI: number; // Identity Match
|
|
6
|
-
wM: number; // Semantic Match (Role/Label over raw CSS)
|
|
7
|
-
wA: number; // Actionability (visible/enabled)
|
|
8
|
-
wH: number; // Historical Execution Reliability
|
|
9
|
-
wD: number; // Volatile Implementation Dependence penalty
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface ScoreBreakdown {
|
|
13
|
-
score: number;
|
|
14
|
-
transformedScore: number;
|
|
15
|
-
calibratedProbability: number; // Retained as alias for transformedScore
|
|
16
|
-
uniqueness: number;
|
|
17
|
-
matchCount: number;
|
|
18
|
-
identityMatch: number;
|
|
19
|
-
semanticMatch: number;
|
|
20
|
-
actionability: number;
|
|
21
|
-
historicalReliability: number;
|
|
22
|
-
historicalExecutionReliability: number;
|
|
23
|
-
volatilePenalty: number;
|
|
24
|
-
conventionAlignment?: number;
|
|
25
|
-
explanation: string[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface SelectorProjectContext {
|
|
29
|
-
preferredLocator?: 'getByRole' | 'getByTestId' | 'getByLabel' | 'getByText' | 'locator' | 'unknown';
|
|
30
|
-
/** Observed locator counts from the repository profile. */
|
|
31
|
-
locatorUsage?: Partial<Record<'getByRole' | 'getByTestId' | 'getByLabel' | 'getByText' | 'locator', number>>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const SEMANTIC_INTENT_GROUPS: string[][] = [
|
|
35
|
-
['login', 'log in', 'signin', 'sign in', 'authenticate'],
|
|
36
|
-
['logout', 'log out', 'signout', 'sign out'],
|
|
37
|
-
['submit', 'save', 'confirm', 'send', 'apply'],
|
|
38
|
-
['cancel', 'close', 'dismiss', 'abort'],
|
|
39
|
-
['checkout', 'pay', 'payment', 'order'],
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
function semanticIntentGroup(value: string): string[] | undefined {
|
|
43
|
-
return SEMANTIC_INTENT_GROUPS.find((group) => group.some((term) => value.includes(term)));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export class SelectorScorer {
|
|
47
|
-
public static readonly DEFAULT_WEIGHTS: ScorerWeights = {
|
|
48
|
-
wU: 0.25,
|
|
49
|
-
wI: 0.25,
|
|
50
|
-
wM: 0.20,
|
|
51
|
-
wA: 0.15,
|
|
52
|
-
wH: 0.10,
|
|
53
|
-
wD: 0.20,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Scores a candidate element against the multi-factor formula:
|
|
58
|
-
* S(L) = w1*U + w2*I + w3*M + w4*A + w5*H - w6*D
|
|
59
|
-
*
|
|
60
|
-
* Uniqueness evaluates whether the proposed locator resolves to exactly one element.
|
|
61
|
-
* Identity requires affirmative token overlap and rejects contradictory semantics.
|
|
62
|
-
* Actionability verifies enabled state and non-hidden status.
|
|
63
|
-
* Produces a transformed score (sigmoid scaling), not an overclaimed empirical probability.
|
|
64
|
-
*/
|
|
65
|
-
public static score(
|
|
66
|
-
candidate: ResolvedElement,
|
|
67
|
-
allCandidates: ResolvedElement[],
|
|
68
|
-
originalSelector?: string,
|
|
69
|
-
historicalScore?: number,
|
|
70
|
-
weights = SelectorScorer.DEFAULT_WEIGHTS,
|
|
71
|
-
projectContext?: SelectorProjectContext,
|
|
72
|
-
): ScoreBreakdown {
|
|
73
|
-
const explanation: string[] = [];
|
|
74
|
-
|
|
75
|
-
// 1. Uniqueness U: Evaluated on actual candidate identifying attributes
|
|
76
|
-
const matchingCount = allCandidates.filter((c) => {
|
|
77
|
-
// If role + name exists, check match on role + name
|
|
78
|
-
if (candidate.role && candidate.accessibleName) {
|
|
79
|
-
return c.role === candidate.role && c.accessibleName === candidate.accessibleName;
|
|
80
|
-
}
|
|
81
|
-
// If testid exists, check match on testid
|
|
82
|
-
const candTestId = candidate.attributes?.['data-testid'] ?? candidate.attributes?.['data-test'];
|
|
83
|
-
if (candTestId) {
|
|
84
|
-
const cTestId = c.attributes?.['data-testid'] ?? c.attributes?.['data-test'];
|
|
85
|
-
return cTestId === candTestId;
|
|
86
|
-
}
|
|
87
|
-
// If id exists, check match on id
|
|
88
|
-
if (candidate.attributes?.['id']) {
|
|
89
|
-
return c.attributes?.['id'] === candidate.attributes?.['id'];
|
|
90
|
-
}
|
|
91
|
-
return c.tag === candidate.tag && c.role === candidate.role && c.accessibleName === candidate.accessibleName;
|
|
92
|
-
}).length;
|
|
93
|
-
|
|
94
|
-
const uniqueness = matchingCount === 1 ? 1.0 : matchingCount > 1 ? parseFloat((1 / matchingCount).toFixed(3)) : 0.0;
|
|
95
|
-
explanation.push(`Uniqueness U: ${uniqueness} (${matchingCount} matching element(s) in scope)`);
|
|
96
|
-
|
|
97
|
-
// 2. Identity Match I: Entity identity and affirmative token overlap
|
|
98
|
-
let identityMatch = candidate.accessibleName ? (originalSelector ? 0.3 : 0.8) : 0.3;
|
|
99
|
-
if (candidate.accessibleName) {
|
|
100
|
-
if (originalSelector) {
|
|
101
|
-
const origNorm = originalSelector.toLowerCase().replace(/[-_]/g, ' ');
|
|
102
|
-
const nameNorm = candidate.accessibleName.toLowerCase().replace(/[-_]/g, ' ');
|
|
103
|
-
const exactIdentityMatch = origNorm.includes(nameNorm) || nameNorm.includes(origNorm);
|
|
104
|
-
|
|
105
|
-
const originalIntent = semanticIntentGroup(origNorm);
|
|
106
|
-
const candidateIntent = semanticIntentGroup(nameNorm);
|
|
107
|
-
if (exactIdentityMatch) {
|
|
108
|
-
identityMatch = 1.0;
|
|
109
|
-
explanation.push('Identity Match I: 1.0 (Exact identity text match detected)');
|
|
110
|
-
} else if (originalIntent && candidateIntent && originalIntent === candidateIntent) {
|
|
111
|
-
identityMatch = 0.98;
|
|
112
|
-
explanation.push('Identity Match I: 0.98 (Equivalent semantic action intent detected)');
|
|
113
|
-
} else if (originalIntent && candidateIntent && originalIntent !== candidateIntent) {
|
|
114
|
-
const negativeIntents = new Set(['logout', 'log out', 'signout', 'sign out', 'cancel', 'close', 'dismiss', 'abort']);
|
|
115
|
-
const originalIsNegative = originalIntent.some((term) => negativeIntents.has(term));
|
|
116
|
-
const candidateIsNegative = candidateIntent.some((term) => negativeIntents.has(term));
|
|
117
|
-
if (originalIsNegative !== candidateIsNegative) {
|
|
118
|
-
identityMatch = 0.0;
|
|
119
|
-
explanation.push('Identity Match I: 0.0 (Conflicting semantic action intent detected)');
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Hard check: Contradictory semantics (submit vs cancel)
|
|
124
|
-
const positiveWords = ['submit', 'login', 'checkout', 'save', 'confirm', 'proceed', 'pay', 'order'];
|
|
125
|
-
const negativeWords = ['cancel', 'close', 'reset', 'abort', 'back', 'dismiss', 'delete'];
|
|
126
|
-
|
|
127
|
-
const origIsPositive = positiveWords.some((t) => origNorm.includes(t));
|
|
128
|
-
const candIsNegative = negativeWords.some((t) => nameNorm.includes(t));
|
|
129
|
-
const origIsNegative = negativeWords.some((t) => origNorm.includes(t));
|
|
130
|
-
const candIsPositive = positiveWords.some((t) => nameNorm.includes(t));
|
|
131
|
-
|
|
132
|
-
// Differentiating action token conflicts (e.g. continue shopping vs continue checkout)
|
|
133
|
-
const distinguishingPairs: Array<[string, string]> = [
|
|
134
|
-
['shopping', 'checkout'],
|
|
135
|
-
['cart', 'checkout'],
|
|
136
|
-
['prev', 'next'],
|
|
137
|
-
['previous', 'next'],
|
|
138
|
-
['first', 'last'],
|
|
139
|
-
['left', 'right'],
|
|
140
|
-
['up', 'down'],
|
|
141
|
-
['yes', 'no'],
|
|
142
|
-
['approve', 'reject'],
|
|
143
|
-
['accept', 'decline'],
|
|
144
|
-
['enable', 'disable'],
|
|
145
|
-
['open', 'close'],
|
|
146
|
-
['edit', 'delete'],
|
|
147
|
-
['create', 'delete'],
|
|
148
|
-
['add', 'delete'],
|
|
149
|
-
['remove', 'edit'],
|
|
150
|
-
['modify', 'delete'],
|
|
151
|
-
];
|
|
152
|
-
|
|
153
|
-
let hasDistinguishingConflict = false;
|
|
154
|
-
for (const [w1, w2] of distinguishingPairs) {
|
|
155
|
-
const origHasW1 = origNorm.includes(w1);
|
|
156
|
-
const origHasW2 = origNorm.includes(w2);
|
|
157
|
-
const candHasW1 = nameNorm.includes(w1);
|
|
158
|
-
const candHasW2 = nameNorm.includes(w2);
|
|
159
|
-
if (
|
|
160
|
-
(origHasW1 && !origHasW2 && candHasW2 && !candHasW1) ||
|
|
161
|
-
(origHasW2 && !origHasW1 && candHasW1 && !candHasW2)
|
|
162
|
-
) {
|
|
163
|
-
hasDistinguishingConflict = true;
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (identityMatch === 0.98 || identityMatch === 0.0) {
|
|
169
|
-
// Preserve the stronger semantic intent result above.
|
|
170
|
-
} else if (hasDistinguishingConflict) {
|
|
171
|
-
identityMatch = 0.1;
|
|
172
|
-
explanation.push(`Identity Match I: 0.1 (Conflicting action token intent detected)`);
|
|
173
|
-
} else if ((origIsPositive && candIsNegative) || (origIsNegative && candIsPositive)) {
|
|
174
|
-
identityMatch = 0.0;
|
|
175
|
-
explanation.push(`Identity Match I: 0.0 (Hard contradiction: target has opposing confirmation/cancellation semantics)`);
|
|
176
|
-
} else if (exactIdentityMatch) {
|
|
177
|
-
identityMatch = 1.0;
|
|
178
|
-
} else {
|
|
179
|
-
// Token overlap evaluating composite candidate identity (accessibleName, text, testid, id)
|
|
180
|
-
const candCompositeText = [
|
|
181
|
-
candidate.accessibleName ?? '',
|
|
182
|
-
candidate.text ?? '',
|
|
183
|
-
candidate.attributes['data-testid'] ?? candidate.attributes['data-test'] ?? '',
|
|
184
|
-
candidate.attributes['id'] ?? '',
|
|
185
|
-
].join(' ').toLowerCase().replace(/[-_]/g, ' ');
|
|
186
|
-
|
|
187
|
-
const origWords = new Set(origNorm.split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
188
|
-
const candWords = new Set(candCompositeText.split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
189
|
-
if (origWords.size > 0 && candWords.size > 0) {
|
|
190
|
-
const intersection = [...origWords].filter((w) => candWords.has(w));
|
|
191
|
-
const overlap = intersection.length / Math.min(origWords.size, candWords.size);
|
|
192
|
-
if (overlap >= 0.5) {
|
|
193
|
-
identityMatch = 0.95;
|
|
194
|
-
} else if (overlap > 0) {
|
|
195
|
-
identityMatch = 0.85;
|
|
196
|
-
} else {
|
|
197
|
-
identityMatch = 0.0;
|
|
198
|
-
explanation.push(`Identity Match I: 0.0 (Zero token overlap or semantic correspondence with original selector)`);
|
|
199
|
-
}
|
|
200
|
-
} else {
|
|
201
|
-
identityMatch = 0.2;
|
|
202
|
-
explanation.push(`Identity Match I: 0.2 (Insufficient token information to prove identity)`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Semantic Role Alignment: If original explicitly specifies button vs link
|
|
207
|
-
const origWantsButton = /\bbutton\b/i.test(originalSelector) || /getByRole\(\s*['"]button['"]/i.test(originalSelector) || /#btn-/i.test(originalSelector);
|
|
208
|
-
const origWantsLink = /\b(link|a)\b/i.test(originalSelector) || /getByRole\(\s*['"]link['"]/i.test(originalSelector);
|
|
209
|
-
|
|
210
|
-
if (origWantsButton && (candidate.role === 'link' || candidate.tag === 'a') && candidate.role !== 'button') {
|
|
211
|
-
identityMatch = Math.max(0, identityMatch - 0.25);
|
|
212
|
-
explanation.push('Role Mismatch Penalty: -0.25 (Original selector requires button, candidate is link/anchor)');
|
|
213
|
-
} else if (origWantsLink && candidate.role === 'button' && candidate.tag !== 'a') {
|
|
214
|
-
identityMatch = Math.max(0, identityMatch - 0.25);
|
|
215
|
-
explanation.push('Role Mismatch Penalty: -0.25 (Original selector requires link, candidate is button)');
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
explanation.push(`Identity Match I: ${identityMatch}`);
|
|
220
|
-
|
|
221
|
-
// 3. Semantic Match M: getByRole / getByLabel priority over raw CSS
|
|
222
|
-
let semanticMatch = 0.4; // Base for raw HTML tag
|
|
223
|
-
if (candidate.role && candidate.accessibleName) {
|
|
224
|
-
semanticMatch = 1.0; // Priority: Role + Accessible Name
|
|
225
|
-
} else if (candidate.attributes['data-testid'] || candidate.attributes['data-test']) {
|
|
226
|
-
semanticMatch = 0.9; // Dedicated Test ID
|
|
227
|
-
} else if (candidate.attributes['placeholder'] || candidate.attributes['aria-label']) {
|
|
228
|
-
semanticMatch = 0.85;
|
|
229
|
-
}
|
|
230
|
-
explanation.push(`Semantic Match M: ${semanticMatch} (Role: ${candidate.role ?? 'none'})`);
|
|
231
|
-
|
|
232
|
-
// 4. Actionability A: Element is enabled and visible
|
|
233
|
-
const isDisabled = Boolean(candidate.attributes['disabled']) || candidate.attributes['aria-disabled'] === 'true';
|
|
234
|
-
const isHidden = candidate.attributes['hidden'] === 'true' || candidate.attributes['aria-hidden'] === 'true';
|
|
235
|
-
const actionability = isDisabled || isHidden ? 0.0 : 1.0;
|
|
236
|
-
explanation.push(`Actionability A: ${actionability} (Disabled: ${isDisabled}, Hidden: ${isHidden})`);
|
|
237
|
-
|
|
238
|
-
// 5. Historical Execution Reliability H: Neutral 0.5 baseline if unrecorded
|
|
239
|
-
const historicalReliability = historicalScore !== undefined
|
|
240
|
-
? Math.max(0, Math.min(1.0, historicalScore))
|
|
241
|
-
: 0.5;
|
|
242
|
-
explanation.push(`Historical Execution Reliability H: ${historicalReliability}`);
|
|
243
|
-
|
|
244
|
-
// 6. Volatile Implementation Dependence D (Penalizes hashed classes and dynamic IDs)
|
|
245
|
-
let volatilePenalty = 0.0;
|
|
246
|
-
const id = candidate.attributes['id'] ?? '';
|
|
247
|
-
const classAttr = candidate.attributes['class'] ?? '';
|
|
248
|
-
|
|
249
|
-
// Check for random hex/hash IDs e.g. "id-a1b2c3d4", React useId ":r1:", or component IDs
|
|
250
|
-
if (
|
|
251
|
-
/^[0-9a-f]{8,}$/i.test(id) ||
|
|
252
|
-
/^[a-z]+-[0-9a-f]{4,}$/i.test(id) ||
|
|
253
|
-
/^:r[0-9a-z]+:$/i.test(id) ||
|
|
254
|
-
/^(ember|chakra|radix)-\d+/i.test(id)
|
|
255
|
-
) {
|
|
256
|
-
volatilePenalty += 0.5;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Check for CSS-in-JS hashed classes e.g. "css-1a2b3c" or "sc-xyz123"
|
|
260
|
-
if (/css-[a-z0-9]+/i.test(classAttr) || /sc-[a-z0-9]+/i.test(classAttr)) {
|
|
261
|
-
volatilePenalty += 0.4;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
volatilePenalty = Math.min(1.0, volatilePenalty);
|
|
265
|
-
explanation.push(`Volatile Penalty D: ${volatilePenalty}`);
|
|
266
|
-
|
|
267
|
-
const candidateKind = candidate.role && candidate.accessibleName
|
|
268
|
-
? 'getByRole'
|
|
269
|
-
: candidate.attributes['data-testid'] || candidate.attributes['data-test']
|
|
270
|
-
? 'getByTestId'
|
|
271
|
-
: candidate.attributes['placeholder'] || candidate.attributes['aria-label']
|
|
272
|
-
? 'getByLabel'
|
|
273
|
-
: candidate.text
|
|
274
|
-
? 'getByText'
|
|
275
|
-
: 'locator';
|
|
276
|
-
const locatorUsage = projectContext?.locatorUsage;
|
|
277
|
-
const usageTotal = locatorUsage
|
|
278
|
-
? Object.values(locatorUsage).reduce((sum, count) => sum + (typeof count === 'number' && count > 0 ? count : 0), 0)
|
|
279
|
-
: 0;
|
|
280
|
-
const conventionAlignment = usageTotal > 0
|
|
281
|
-
? Math.max(0, Math.min(1, (locatorUsage?.[candidateKind] ?? 0) / usageTotal))
|
|
282
|
-
: projectContext?.preferredLocator && projectContext.preferredLocator !== 'unknown'
|
|
283
|
-
? projectContext.preferredLocator === candidateKind ? 1 : 0
|
|
284
|
-
: 0.5;
|
|
285
|
-
// Keep convention influence deliberately small: observed project style can
|
|
286
|
-
// break a close tie, but never compensate for weak identity or uniqueness.
|
|
287
|
-
const conventionBonus = usageTotal > 0 || (projectContext?.preferredLocator && projectContext.preferredLocator !== 'unknown')
|
|
288
|
-
? (conventionAlignment - 0.5) * 0.04
|
|
289
|
-
: 0;
|
|
290
|
-
explanation.push(`Project Convention C: ${conventionAlignment.toFixed(3)} (${candidateKind}${projectContext?.preferredLocator ? `; preferred ${projectContext.preferredLocator}` : ''}${usageTotal > 0 ? `; observed ${usageTotal}` : ''})`);
|
|
291
|
-
|
|
292
|
-
// Calculate final composite score S(L)
|
|
293
|
-
const rawScore =
|
|
294
|
-
weights.wU * uniqueness +
|
|
295
|
-
weights.wI * identityMatch +
|
|
296
|
-
weights.wM * semanticMatch +
|
|
297
|
-
weights.wA * actionability +
|
|
298
|
-
weights.wH * historicalReliability -
|
|
299
|
-
weights.wD * volatilePenalty +
|
|
300
|
-
conventionBonus;
|
|
301
|
-
|
|
302
|
-
const finalScore = Math.max(0, Math.min(1.0, parseFloat(rawScore.toFixed(3))));
|
|
303
|
-
|
|
304
|
-
// Intent Gate: If candidate has no token overlap or conflicting identity,
|
|
305
|
-
// cap the transformed score to strictly prevent false repairs on missing/unrelated actions.
|
|
306
|
-
if (identityMatch <= 0.1) {
|
|
307
|
-
explanation.push('Intent Gated: Zero/conflicting identity match caps transformed confidence to 0.05');
|
|
308
|
-
return {
|
|
309
|
-
score: Math.min(0.15, finalScore),
|
|
310
|
-
transformedScore: 0.05,
|
|
311
|
-
calibratedProbability: 0.05,
|
|
312
|
-
uniqueness,
|
|
313
|
-
matchCount: matchingCount,
|
|
314
|
-
identityMatch,
|
|
315
|
-
semanticMatch,
|
|
316
|
-
actionability,
|
|
317
|
-
historicalReliability,
|
|
318
|
-
historicalExecutionReliability: historicalReliability,
|
|
319
|
-
volatilePenalty,
|
|
320
|
-
conventionAlignment,
|
|
321
|
-
explanation,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Transformed confidence score via logistic sigmoid scaling
|
|
326
|
-
const transformedScore = parseFloat((1 / (1 + Math.exp(-6 * (finalScore - 0.65)))).toFixed(3));
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
score: finalScore,
|
|
330
|
-
transformedScore,
|
|
331
|
-
calibratedProbability: transformedScore, // Backward compatibility alias
|
|
332
|
-
uniqueness,
|
|
333
|
-
matchCount: matchingCount,
|
|
334
|
-
identityMatch,
|
|
335
|
-
semanticMatch,
|
|
336
|
-
actionability,
|
|
337
|
-
historicalReliability,
|
|
338
|
-
historicalExecutionReliability: historicalReliability,
|
|
339
|
-
volatilePenalty,
|
|
340
|
-
conventionAlignment,
|
|
341
|
-
explanation,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
}
|
|
1
|
+
import { ResolvedElement } from '../trace/snapshot-resolver.js';
|
|
2
|
+
|
|
3
|
+
export interface ScorerWeights {
|
|
4
|
+
wU: number; // Uniqueness
|
|
5
|
+
wI: number; // Identity Match
|
|
6
|
+
wM: number; // Semantic Match (Role/Label over raw CSS)
|
|
7
|
+
wA: number; // Actionability (visible/enabled)
|
|
8
|
+
wH: number; // Historical Execution Reliability
|
|
9
|
+
wD: number; // Volatile Implementation Dependence penalty
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ScoreBreakdown {
|
|
13
|
+
score: number;
|
|
14
|
+
transformedScore: number;
|
|
15
|
+
calibratedProbability: number; // Retained as alias for transformedScore
|
|
16
|
+
uniqueness: number;
|
|
17
|
+
matchCount: number;
|
|
18
|
+
identityMatch: number;
|
|
19
|
+
semanticMatch: number;
|
|
20
|
+
actionability: number;
|
|
21
|
+
historicalReliability: number;
|
|
22
|
+
historicalExecutionReliability: number;
|
|
23
|
+
volatilePenalty: number;
|
|
24
|
+
conventionAlignment?: number;
|
|
25
|
+
explanation: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SelectorProjectContext {
|
|
29
|
+
preferredLocator?: 'getByRole' | 'getByTestId' | 'getByLabel' | 'getByText' | 'locator' | 'unknown';
|
|
30
|
+
/** Observed locator counts from the repository profile. */
|
|
31
|
+
locatorUsage?: Partial<Record<'getByRole' | 'getByTestId' | 'getByLabel' | 'getByText' | 'locator', number>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SEMANTIC_INTENT_GROUPS: string[][] = [
|
|
35
|
+
['login', 'log in', 'signin', 'sign in', 'authenticate'],
|
|
36
|
+
['logout', 'log out', 'signout', 'sign out'],
|
|
37
|
+
['submit', 'save', 'confirm', 'send', 'apply'],
|
|
38
|
+
['cancel', 'close', 'dismiss', 'abort'],
|
|
39
|
+
['checkout', 'pay', 'payment', 'order'],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function semanticIntentGroup(value: string): string[] | undefined {
|
|
43
|
+
return SEMANTIC_INTENT_GROUPS.find((group) => group.some((term) => value.includes(term)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SelectorScorer {
|
|
47
|
+
public static readonly DEFAULT_WEIGHTS: ScorerWeights = {
|
|
48
|
+
wU: 0.25,
|
|
49
|
+
wI: 0.25,
|
|
50
|
+
wM: 0.20,
|
|
51
|
+
wA: 0.15,
|
|
52
|
+
wH: 0.10,
|
|
53
|
+
wD: 0.20,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Scores a candidate element against the multi-factor formula:
|
|
58
|
+
* S(L) = w1*U + w2*I + w3*M + w4*A + w5*H - w6*D
|
|
59
|
+
*
|
|
60
|
+
* Uniqueness evaluates whether the proposed locator resolves to exactly one element.
|
|
61
|
+
* Identity requires affirmative token overlap and rejects contradictory semantics.
|
|
62
|
+
* Actionability verifies enabled state and non-hidden status.
|
|
63
|
+
* Produces a transformed score (sigmoid scaling), not an overclaimed empirical probability.
|
|
64
|
+
*/
|
|
65
|
+
public static score(
|
|
66
|
+
candidate: ResolvedElement,
|
|
67
|
+
allCandidates: ResolvedElement[],
|
|
68
|
+
originalSelector?: string,
|
|
69
|
+
historicalScore?: number,
|
|
70
|
+
weights = SelectorScorer.DEFAULT_WEIGHTS,
|
|
71
|
+
projectContext?: SelectorProjectContext,
|
|
72
|
+
): ScoreBreakdown {
|
|
73
|
+
const explanation: string[] = [];
|
|
74
|
+
|
|
75
|
+
// 1. Uniqueness U: Evaluated on actual candidate identifying attributes
|
|
76
|
+
const matchingCount = allCandidates.filter((c) => {
|
|
77
|
+
// If role + name exists, check match on role + name
|
|
78
|
+
if (candidate.role && candidate.accessibleName) {
|
|
79
|
+
return c.role === candidate.role && c.accessibleName === candidate.accessibleName;
|
|
80
|
+
}
|
|
81
|
+
// If testid exists, check match on testid
|
|
82
|
+
const candTestId = candidate.attributes?.['data-testid'] ?? candidate.attributes?.['data-test'];
|
|
83
|
+
if (candTestId) {
|
|
84
|
+
const cTestId = c.attributes?.['data-testid'] ?? c.attributes?.['data-test'];
|
|
85
|
+
return cTestId === candTestId;
|
|
86
|
+
}
|
|
87
|
+
// If id exists, check match on id
|
|
88
|
+
if (candidate.attributes?.['id']) {
|
|
89
|
+
return c.attributes?.['id'] === candidate.attributes?.['id'];
|
|
90
|
+
}
|
|
91
|
+
return c.tag === candidate.tag && c.role === candidate.role && c.accessibleName === candidate.accessibleName;
|
|
92
|
+
}).length;
|
|
93
|
+
|
|
94
|
+
const uniqueness = matchingCount === 1 ? 1.0 : matchingCount > 1 ? parseFloat((1 / matchingCount).toFixed(3)) : 0.0;
|
|
95
|
+
explanation.push(`Uniqueness U: ${uniqueness} (${matchingCount} matching element(s) in scope)`);
|
|
96
|
+
|
|
97
|
+
// 2. Identity Match I: Entity identity and affirmative token overlap
|
|
98
|
+
let identityMatch = candidate.accessibleName ? (originalSelector ? 0.3 : 0.8) : 0.3;
|
|
99
|
+
if (candidate.accessibleName) {
|
|
100
|
+
if (originalSelector) {
|
|
101
|
+
const origNorm = originalSelector.toLowerCase().replace(/[-_]/g, ' ');
|
|
102
|
+
const nameNorm = candidate.accessibleName.toLowerCase().replace(/[-_]/g, ' ');
|
|
103
|
+
const exactIdentityMatch = origNorm.includes(nameNorm) || nameNorm.includes(origNorm);
|
|
104
|
+
|
|
105
|
+
const originalIntent = semanticIntentGroup(origNorm);
|
|
106
|
+
const candidateIntent = semanticIntentGroup(nameNorm);
|
|
107
|
+
if (exactIdentityMatch) {
|
|
108
|
+
identityMatch = 1.0;
|
|
109
|
+
explanation.push('Identity Match I: 1.0 (Exact identity text match detected)');
|
|
110
|
+
} else if (originalIntent && candidateIntent && originalIntent === candidateIntent) {
|
|
111
|
+
identityMatch = 0.98;
|
|
112
|
+
explanation.push('Identity Match I: 0.98 (Equivalent semantic action intent detected)');
|
|
113
|
+
} else if (originalIntent && candidateIntent && originalIntent !== candidateIntent) {
|
|
114
|
+
const negativeIntents = new Set(['logout', 'log out', 'signout', 'sign out', 'cancel', 'close', 'dismiss', 'abort']);
|
|
115
|
+
const originalIsNegative = originalIntent.some((term) => negativeIntents.has(term));
|
|
116
|
+
const candidateIsNegative = candidateIntent.some((term) => negativeIntents.has(term));
|
|
117
|
+
if (originalIsNegative !== candidateIsNegative) {
|
|
118
|
+
identityMatch = 0.0;
|
|
119
|
+
explanation.push('Identity Match I: 0.0 (Conflicting semantic action intent detected)');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Hard check: Contradictory semantics (submit vs cancel)
|
|
124
|
+
const positiveWords = ['submit', 'login', 'checkout', 'save', 'confirm', 'proceed', 'pay', 'order'];
|
|
125
|
+
const negativeWords = ['cancel', 'close', 'reset', 'abort', 'back', 'dismiss', 'delete'];
|
|
126
|
+
|
|
127
|
+
const origIsPositive = positiveWords.some((t) => origNorm.includes(t));
|
|
128
|
+
const candIsNegative = negativeWords.some((t) => nameNorm.includes(t));
|
|
129
|
+
const origIsNegative = negativeWords.some((t) => origNorm.includes(t));
|
|
130
|
+
const candIsPositive = positiveWords.some((t) => nameNorm.includes(t));
|
|
131
|
+
|
|
132
|
+
// Differentiating action token conflicts (e.g. continue shopping vs continue checkout)
|
|
133
|
+
const distinguishingPairs: Array<[string, string]> = [
|
|
134
|
+
['shopping', 'checkout'],
|
|
135
|
+
['cart', 'checkout'],
|
|
136
|
+
['prev', 'next'],
|
|
137
|
+
['previous', 'next'],
|
|
138
|
+
['first', 'last'],
|
|
139
|
+
['left', 'right'],
|
|
140
|
+
['up', 'down'],
|
|
141
|
+
['yes', 'no'],
|
|
142
|
+
['approve', 'reject'],
|
|
143
|
+
['accept', 'decline'],
|
|
144
|
+
['enable', 'disable'],
|
|
145
|
+
['open', 'close'],
|
|
146
|
+
['edit', 'delete'],
|
|
147
|
+
['create', 'delete'],
|
|
148
|
+
['add', 'delete'],
|
|
149
|
+
['remove', 'edit'],
|
|
150
|
+
['modify', 'delete'],
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
let hasDistinguishingConflict = false;
|
|
154
|
+
for (const [w1, w2] of distinguishingPairs) {
|
|
155
|
+
const origHasW1 = origNorm.includes(w1);
|
|
156
|
+
const origHasW2 = origNorm.includes(w2);
|
|
157
|
+
const candHasW1 = nameNorm.includes(w1);
|
|
158
|
+
const candHasW2 = nameNorm.includes(w2);
|
|
159
|
+
if (
|
|
160
|
+
(origHasW1 && !origHasW2 && candHasW2 && !candHasW1) ||
|
|
161
|
+
(origHasW2 && !origHasW1 && candHasW1 && !candHasW2)
|
|
162
|
+
) {
|
|
163
|
+
hasDistinguishingConflict = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (identityMatch === 0.98 || identityMatch === 0.0) {
|
|
169
|
+
// Preserve the stronger semantic intent result above.
|
|
170
|
+
} else if (hasDistinguishingConflict) {
|
|
171
|
+
identityMatch = 0.1;
|
|
172
|
+
explanation.push(`Identity Match I: 0.1 (Conflicting action token intent detected)`);
|
|
173
|
+
} else if ((origIsPositive && candIsNegative) || (origIsNegative && candIsPositive)) {
|
|
174
|
+
identityMatch = 0.0;
|
|
175
|
+
explanation.push(`Identity Match I: 0.0 (Hard contradiction: target has opposing confirmation/cancellation semantics)`);
|
|
176
|
+
} else if (exactIdentityMatch) {
|
|
177
|
+
identityMatch = 1.0;
|
|
178
|
+
} else {
|
|
179
|
+
// Token overlap evaluating composite candidate identity (accessibleName, text, testid, id)
|
|
180
|
+
const candCompositeText = [
|
|
181
|
+
candidate.accessibleName ?? '',
|
|
182
|
+
candidate.text ?? '',
|
|
183
|
+
candidate.attributes['data-testid'] ?? candidate.attributes['data-test'] ?? '',
|
|
184
|
+
candidate.attributes['id'] ?? '',
|
|
185
|
+
].join(' ').toLowerCase().replace(/[-_]/g, ' ');
|
|
186
|
+
|
|
187
|
+
const origWords = new Set(origNorm.split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
188
|
+
const candWords = new Set(candCompositeText.split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
189
|
+
if (origWords.size > 0 && candWords.size > 0) {
|
|
190
|
+
const intersection = [...origWords].filter((w) => candWords.has(w));
|
|
191
|
+
const overlap = intersection.length / Math.min(origWords.size, candWords.size);
|
|
192
|
+
if (overlap >= 0.5) {
|
|
193
|
+
identityMatch = 0.95;
|
|
194
|
+
} else if (overlap > 0) {
|
|
195
|
+
identityMatch = 0.85;
|
|
196
|
+
} else {
|
|
197
|
+
identityMatch = 0.0;
|
|
198
|
+
explanation.push(`Identity Match I: 0.0 (Zero token overlap or semantic correspondence with original selector)`);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
identityMatch = 0.2;
|
|
202
|
+
explanation.push(`Identity Match I: 0.2 (Insufficient token information to prove identity)`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Semantic Role Alignment: If original explicitly specifies button vs link
|
|
207
|
+
const origWantsButton = /\bbutton\b/i.test(originalSelector) || /getByRole\(\s*['"]button['"]/i.test(originalSelector) || /#btn-/i.test(originalSelector);
|
|
208
|
+
const origWantsLink = /\b(link|a)\b/i.test(originalSelector) || /getByRole\(\s*['"]link['"]/i.test(originalSelector);
|
|
209
|
+
|
|
210
|
+
if (origWantsButton && (candidate.role === 'link' || candidate.tag === 'a') && candidate.role !== 'button') {
|
|
211
|
+
identityMatch = Math.max(0, identityMatch - 0.25);
|
|
212
|
+
explanation.push('Role Mismatch Penalty: -0.25 (Original selector requires button, candidate is link/anchor)');
|
|
213
|
+
} else if (origWantsLink && candidate.role === 'button' && candidate.tag !== 'a') {
|
|
214
|
+
identityMatch = Math.max(0, identityMatch - 0.25);
|
|
215
|
+
explanation.push('Role Mismatch Penalty: -0.25 (Original selector requires link, candidate is button)');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
explanation.push(`Identity Match I: ${identityMatch}`);
|
|
220
|
+
|
|
221
|
+
// 3. Semantic Match M: getByRole / getByLabel priority over raw CSS
|
|
222
|
+
let semanticMatch = 0.4; // Base for raw HTML tag
|
|
223
|
+
if (candidate.role && candidate.accessibleName) {
|
|
224
|
+
semanticMatch = 1.0; // Priority: Role + Accessible Name
|
|
225
|
+
} else if (candidate.attributes['data-testid'] || candidate.attributes['data-test']) {
|
|
226
|
+
semanticMatch = 0.9; // Dedicated Test ID
|
|
227
|
+
} else if (candidate.attributes['placeholder'] || candidate.attributes['aria-label']) {
|
|
228
|
+
semanticMatch = 0.85;
|
|
229
|
+
}
|
|
230
|
+
explanation.push(`Semantic Match M: ${semanticMatch} (Role: ${candidate.role ?? 'none'})`);
|
|
231
|
+
|
|
232
|
+
// 4. Actionability A: Element is enabled and visible
|
|
233
|
+
const isDisabled = Boolean(candidate.attributes['disabled']) || candidate.attributes['aria-disabled'] === 'true';
|
|
234
|
+
const isHidden = candidate.attributes['hidden'] === 'true' || candidate.attributes['aria-hidden'] === 'true';
|
|
235
|
+
const actionability = isDisabled || isHidden ? 0.0 : 1.0;
|
|
236
|
+
explanation.push(`Actionability A: ${actionability} (Disabled: ${isDisabled}, Hidden: ${isHidden})`);
|
|
237
|
+
|
|
238
|
+
// 5. Historical Execution Reliability H: Neutral 0.5 baseline if unrecorded
|
|
239
|
+
const historicalReliability = historicalScore !== undefined
|
|
240
|
+
? Math.max(0, Math.min(1.0, historicalScore))
|
|
241
|
+
: 0.5;
|
|
242
|
+
explanation.push(`Historical Execution Reliability H: ${historicalReliability}`);
|
|
243
|
+
|
|
244
|
+
// 6. Volatile Implementation Dependence D (Penalizes hashed classes and dynamic IDs)
|
|
245
|
+
let volatilePenalty = 0.0;
|
|
246
|
+
const id = candidate.attributes['id'] ?? '';
|
|
247
|
+
const classAttr = candidate.attributes['class'] ?? '';
|
|
248
|
+
|
|
249
|
+
// Check for random hex/hash IDs e.g. "id-a1b2c3d4", React useId ":r1:", or component IDs
|
|
250
|
+
if (
|
|
251
|
+
/^[0-9a-f]{8,}$/i.test(id) ||
|
|
252
|
+
/^[a-z]+-[0-9a-f]{4,}$/i.test(id) ||
|
|
253
|
+
/^:r[0-9a-z]+:$/i.test(id) ||
|
|
254
|
+
/^(ember|chakra|radix)-\d+/i.test(id)
|
|
255
|
+
) {
|
|
256
|
+
volatilePenalty += 0.5;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check for CSS-in-JS hashed classes e.g. "css-1a2b3c" or "sc-xyz123"
|
|
260
|
+
if (/css-[a-z0-9]+/i.test(classAttr) || /sc-[a-z0-9]+/i.test(classAttr)) {
|
|
261
|
+
volatilePenalty += 0.4;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
volatilePenalty = Math.min(1.0, volatilePenalty);
|
|
265
|
+
explanation.push(`Volatile Penalty D: ${volatilePenalty}`);
|
|
266
|
+
|
|
267
|
+
const candidateKind = candidate.role && candidate.accessibleName
|
|
268
|
+
? 'getByRole'
|
|
269
|
+
: candidate.attributes['data-testid'] || candidate.attributes['data-test']
|
|
270
|
+
? 'getByTestId'
|
|
271
|
+
: candidate.attributes['placeholder'] || candidate.attributes['aria-label']
|
|
272
|
+
? 'getByLabel'
|
|
273
|
+
: candidate.text
|
|
274
|
+
? 'getByText'
|
|
275
|
+
: 'locator';
|
|
276
|
+
const locatorUsage = projectContext?.locatorUsage;
|
|
277
|
+
const usageTotal = locatorUsage
|
|
278
|
+
? Object.values(locatorUsage).reduce((sum, count) => sum + (typeof count === 'number' && count > 0 ? count : 0), 0)
|
|
279
|
+
: 0;
|
|
280
|
+
const conventionAlignment = usageTotal > 0
|
|
281
|
+
? Math.max(0, Math.min(1, (locatorUsage?.[candidateKind] ?? 0) / usageTotal))
|
|
282
|
+
: projectContext?.preferredLocator && projectContext.preferredLocator !== 'unknown'
|
|
283
|
+
? projectContext.preferredLocator === candidateKind ? 1 : 0
|
|
284
|
+
: 0.5;
|
|
285
|
+
// Keep convention influence deliberately small: observed project style can
|
|
286
|
+
// break a close tie, but never compensate for weak identity or uniqueness.
|
|
287
|
+
const conventionBonus = usageTotal > 0 || (projectContext?.preferredLocator && projectContext.preferredLocator !== 'unknown')
|
|
288
|
+
? (conventionAlignment - 0.5) * 0.04
|
|
289
|
+
: 0;
|
|
290
|
+
explanation.push(`Project Convention C: ${conventionAlignment.toFixed(3)} (${candidateKind}${projectContext?.preferredLocator ? `; preferred ${projectContext.preferredLocator}` : ''}${usageTotal > 0 ? `; observed ${usageTotal}` : ''})`);
|
|
291
|
+
|
|
292
|
+
// Calculate final composite score S(L)
|
|
293
|
+
const rawScore =
|
|
294
|
+
weights.wU * uniqueness +
|
|
295
|
+
weights.wI * identityMatch +
|
|
296
|
+
weights.wM * semanticMatch +
|
|
297
|
+
weights.wA * actionability +
|
|
298
|
+
weights.wH * historicalReliability -
|
|
299
|
+
weights.wD * volatilePenalty +
|
|
300
|
+
conventionBonus;
|
|
301
|
+
|
|
302
|
+
const finalScore = Math.max(0, Math.min(1.0, parseFloat(rawScore.toFixed(3))));
|
|
303
|
+
|
|
304
|
+
// Intent Gate: If candidate has no token overlap or conflicting identity,
|
|
305
|
+
// cap the transformed score to strictly prevent false repairs on missing/unrelated actions.
|
|
306
|
+
if (identityMatch <= 0.1) {
|
|
307
|
+
explanation.push('Intent Gated: Zero/conflicting identity match caps transformed confidence to 0.05');
|
|
308
|
+
return {
|
|
309
|
+
score: Math.min(0.15, finalScore),
|
|
310
|
+
transformedScore: 0.05,
|
|
311
|
+
calibratedProbability: 0.05,
|
|
312
|
+
uniqueness,
|
|
313
|
+
matchCount: matchingCount,
|
|
314
|
+
identityMatch,
|
|
315
|
+
semanticMatch,
|
|
316
|
+
actionability,
|
|
317
|
+
historicalReliability,
|
|
318
|
+
historicalExecutionReliability: historicalReliability,
|
|
319
|
+
volatilePenalty,
|
|
320
|
+
conventionAlignment,
|
|
321
|
+
explanation,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Transformed confidence score via logistic sigmoid scaling
|
|
326
|
+
const transformedScore = parseFloat((1 / (1 + Math.exp(-6 * (finalScore - 0.65)))).toFixed(3));
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
score: finalScore,
|
|
330
|
+
transformedScore,
|
|
331
|
+
calibratedProbability: transformedScore, // Backward compatibility alias
|
|
332
|
+
uniqueness,
|
|
333
|
+
matchCount: matchingCount,
|
|
334
|
+
identityMatch,
|
|
335
|
+
semanticMatch,
|
|
336
|
+
actionability,
|
|
337
|
+
historicalReliability,
|
|
338
|
+
historicalExecutionReliability: historicalReliability,
|
|
339
|
+
volatilePenalty,
|
|
340
|
+
conventionAlignment,
|
|
341
|
+
explanation,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|