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,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
+ }