sela-core 1.0.0

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 (144) hide show
  1. package/README.md +93 -0
  2. package/bin/sela.js +3 -0
  3. package/dist/cli/ErrorHandler.d.ts +10 -0
  4. package/dist/cli/ErrorHandler.d.ts.map +1 -0
  5. package/dist/cli/ErrorHandler.js +70 -0
  6. package/dist/cli/commands/bulk.d.ts +3 -0
  7. package/dist/cli/commands/bulk.d.ts.map +1 -0
  8. package/dist/cli/commands/bulk.js +140 -0
  9. package/dist/cli/commands/find.d.ts +3 -0
  10. package/dist/cli/commands/find.d.ts.map +1 -0
  11. package/dist/cli/commands/find.js +51 -0
  12. package/dist/cli/commands/init.d.ts +3 -0
  13. package/dist/cli/commands/init.d.ts.map +1 -0
  14. package/dist/cli/commands/init.js +133 -0
  15. package/dist/cli/commands/list.d.ts +3 -0
  16. package/dist/cli/commands/list.d.ts.map +1 -0
  17. package/dist/cli/commands/list.js +56 -0
  18. package/dist/cli/commands/refactor.d.ts +3 -0
  19. package/dist/cli/commands/refactor.d.ts.map +1 -0
  20. package/dist/cli/commands/refactor.js +30 -0
  21. package/dist/cli/commands/status.d.ts +3 -0
  22. package/dist/cli/commands/status.d.ts.map +1 -0
  23. package/dist/cli/commands/status.js +51 -0
  24. package/dist/cli/commands/sync.d.ts +3 -0
  25. package/dist/cli/commands/sync.d.ts.map +1 -0
  26. package/dist/cli/commands/sync.js +123 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.d.ts.map +1 -0
  29. package/dist/cli/index.js +42 -0
  30. package/dist/cli/ui/DnaTable.d.ts +3 -0
  31. package/dist/cli/ui/DnaTable.d.ts.map +1 -0
  32. package/dist/cli/ui/DnaTable.js +70 -0
  33. package/dist/cli/ui/LocatorPicker.d.ts +8 -0
  34. package/dist/cli/ui/LocatorPicker.d.ts.map +1 -0
  35. package/dist/cli/ui/LocatorPicker.js +33 -0
  36. package/dist/cli/ui/ProgressReporter.d.ts +11 -0
  37. package/dist/cli/ui/ProgressReporter.d.ts.map +1 -0
  38. package/dist/cli/ui/ProgressReporter.js +33 -0
  39. package/dist/cli/ui/RefactorWizard.d.ts +26 -0
  40. package/dist/cli/ui/RefactorWizard.d.ts.map +1 -0
  41. package/dist/cli/ui/RefactorWizard.js +269 -0
  42. package/dist/config/ConfigLoader.d.ts +15 -0
  43. package/dist/config/ConfigLoader.d.ts.map +1 -0
  44. package/dist/config/ConfigLoader.js +174 -0
  45. package/dist/config/SelaConfig.d.ts +67 -0
  46. package/dist/config/SelaConfig.d.ts.map +1 -0
  47. package/dist/config/SelaConfig.js +57 -0
  48. package/dist/config/defineConfig.d.ts +3 -0
  49. package/dist/config/defineConfig.d.ts.map +1 -0
  50. package/dist/config/defineConfig.js +9 -0
  51. package/dist/engine/FixwrightEngine.d.ts +24 -0
  52. package/dist/engine/FixwrightEngine.d.ts.map +1 -0
  53. package/dist/engine/FixwrightEngine.js +403 -0
  54. package/dist/engine/HealingRegistry.d.ts +40 -0
  55. package/dist/engine/HealingRegistry.d.ts.map +1 -0
  56. package/dist/engine/HealingRegistry.js +98 -0
  57. package/dist/engine/singleton.d.ts +3 -0
  58. package/dist/engine/singleton.d.ts.map +1 -0
  59. package/dist/engine/singleton.js +5 -0
  60. package/dist/fixtures/expectProxy.d.ts +12 -0
  61. package/dist/fixtures/expectProxy.d.ts.map +1 -0
  62. package/dist/fixtures/expectProxy.js +228 -0
  63. package/dist/fixtures/index.d.ts +6 -0
  64. package/dist/fixtures/index.d.ts.map +1 -0
  65. package/dist/fixtures/index.js +688 -0
  66. package/dist/fixtures/moduleExpect.d.ts +2 -0
  67. package/dist/fixtures/moduleExpect.d.ts.map +1 -0
  68. package/dist/fixtures/moduleExpect.js +46 -0
  69. package/dist/index.d.ts +7 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +28 -0
  72. package/dist/services/ASTSourceUpdater.d.ts +79 -0
  73. package/dist/services/ASTSourceUpdater.d.ts.map +1 -0
  74. package/dist/services/ASTSourceUpdater.js +3177 -0
  75. package/dist/services/ArgumentTypeAnalyzer.d.ts +26 -0
  76. package/dist/services/ArgumentTypeAnalyzer.d.ts.map +1 -0
  77. package/dist/services/ArgumentTypeAnalyzer.js +92 -0
  78. package/dist/services/BlastRadiusAnalyzer.d.ts +15 -0
  79. package/dist/services/BlastRadiusAnalyzer.d.ts.map +1 -0
  80. package/dist/services/BlastRadiusAnalyzer.js +103 -0
  81. package/dist/services/ChainValidator.d.ts +76 -0
  82. package/dist/services/ChainValidator.d.ts.map +1 -0
  83. package/dist/services/ChainValidator.js +569 -0
  84. package/dist/services/CrossFileHealer.d.ts +6 -0
  85. package/dist/services/CrossFileHealer.d.ts.map +1 -0
  86. package/dist/services/CrossFileHealer.js +134 -0
  87. package/dist/services/DefinitionTracer.d.ts +41 -0
  88. package/dist/services/DefinitionTracer.d.ts.map +1 -0
  89. package/dist/services/DefinitionTracer.js +350 -0
  90. package/dist/services/DnaEditorService.d.ts +31 -0
  91. package/dist/services/DnaEditorService.d.ts.map +1 -0
  92. package/dist/services/DnaEditorService.js +198 -0
  93. package/dist/services/DnaIndexService.d.ts +24 -0
  94. package/dist/services/DnaIndexService.d.ts.map +1 -0
  95. package/dist/services/DnaIndexService.js +131 -0
  96. package/dist/services/HealingAdvisory.d.ts +22 -0
  97. package/dist/services/HealingAdvisory.d.ts.map +1 -0
  98. package/dist/services/HealingAdvisory.js +42 -0
  99. package/dist/services/HealthReportService.d.ts +10 -0
  100. package/dist/services/HealthReportService.d.ts.map +1 -0
  101. package/dist/services/HealthReportService.js +84 -0
  102. package/dist/services/InitializerUpdater.d.ts +16 -0
  103. package/dist/services/InitializerUpdater.d.ts.map +1 -0
  104. package/dist/services/InitializerUpdater.js +37 -0
  105. package/dist/services/IntentAuditor.d.ts +39 -0
  106. package/dist/services/IntentAuditor.d.ts.map +1 -0
  107. package/dist/services/IntentAuditor.js +302 -0
  108. package/dist/services/LLMService.d.ts +100 -0
  109. package/dist/services/LLMService.d.ts.map +1 -0
  110. package/dist/services/LLMService.js +439 -0
  111. package/dist/services/SafetyGuard.d.ts +65 -0
  112. package/dist/services/SafetyGuard.d.ts.map +1 -0
  113. package/dist/services/SafetyGuard.js +524 -0
  114. package/dist/services/SnapshotService.d.ts +11 -0
  115. package/dist/services/SnapshotService.d.ts.map +1 -0
  116. package/dist/services/SnapshotService.js +349 -0
  117. package/dist/services/SourceLinkService.d.ts +26 -0
  118. package/dist/services/SourceLinkService.d.ts.map +1 -0
  119. package/dist/services/SourceLinkService.js +156 -0
  120. package/dist/services/SourceUpdater.d.ts +45 -0
  121. package/dist/services/SourceUpdater.d.ts.map +1 -0
  122. package/dist/services/SourceUpdater.js +1021 -0
  123. package/dist/services/TemplateDiffService.d.ts +25 -0
  124. package/dist/services/TemplateDiffService.d.ts.map +1 -0
  125. package/dist/services/TemplateDiffService.js +331 -0
  126. package/dist/services/types.d.ts +59 -0
  127. package/dist/services/types.d.ts.map +1 -0
  128. package/dist/services/types.js +2 -0
  129. package/dist/storage/SnapshotManager.d.ts +5 -0
  130. package/dist/storage/SnapshotManager.d.ts.map +1 -0
  131. package/dist/storage/SnapshotManager.js +31 -0
  132. package/dist/types/index.d.ts +95 -0
  133. package/dist/types/index.d.ts.map +1 -0
  134. package/dist/types/index.js +7 -0
  135. package/dist/utils/DOMUtils.d.ts +33 -0
  136. package/dist/utils/DOMUtils.d.ts.map +1 -0
  137. package/dist/utils/DOMUtils.js +179 -0
  138. package/dist/utils/StackUtils.d.ts +11 -0
  139. package/dist/utils/StackUtils.d.ts.map +1 -0
  140. package/dist/utils/StackUtils.js +120 -0
  141. package/dist/vendor/enquirer.d.ts +33 -0
  142. package/dist/vendor/enquirer.d.ts.map +1 -0
  143. package/dist/vendor/enquirer.js +11 -0
  144. package/package.json +67 -0
@@ -0,0 +1,1021 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SourceUpdater = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const child_process_1 = require("child_process");
40
+ const ASTSourceUpdater_1 = require("./ASTSourceUpdater");
41
+ // ─────────────────────────────────────────────────────────────────
42
+ // QUOTE MANAGEMENT
43
+ // ─────────────────────────────────────────────────────────────────
44
+ const astUpdater = new ASTSourceUpdater_1.ASTSourceUpdater();
45
+ function detectOuterQuote(line, target) {
46
+ const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+ for (const q of [`"`, `'`, "`"]) {
48
+ if (new RegExp(`${q}${escaped}${q}`).test(line))
49
+ return q;
50
+ }
51
+ return null;
52
+ }
53
+ function sanitizeForInjection(selector, outerQuote) {
54
+ switch (outerQuote) {
55
+ case `"`:
56
+ return selector.replace(/"/g, `'`);
57
+ case `'`:
58
+ return selector.replace(/'/g, `"`);
59
+ case "`":
60
+ return selector.replace(/`/g, "\\`");
61
+ default:
62
+ return selector.replace(/"/g, `'`);
63
+ }
64
+ }
65
+ function replaceStringLiteralInLine(line, target, replacement) {
66
+ const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ for (const q of [`"`, `'`, "`"]) {
68
+ const regex = new RegExp(`${q}${escaped}${q}`);
69
+ if (regex.test(line)) {
70
+ const safeReplacement = sanitizeForInjection(replacement, q);
71
+ return line.replace(regex, `${q}${safeReplacement}${q}`);
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ // ─────────────────────────────────────────────────────────────────
77
+ // DEDUPLICATION
78
+ // ─────────────────────────────────────────────────────────────────
79
+ function deduplicateSelector(aiSelector, surroundingChain) {
80
+ const aiParts = aiSelector.split(">>").map((s) => s.trim());
81
+ const chainLower = surroundingChain.toLowerCase();
82
+ let startIdx = 0;
83
+ for (let i = 0; i < aiParts.length - 1; i++) {
84
+ const part = aiParts[i].replace(/['"]/g, "").toLowerCase();
85
+ if (chainLower.includes(part)) {
86
+ startIdx = i + 1;
87
+ }
88
+ else {
89
+ break;
90
+ }
91
+ }
92
+ return aiParts.slice(startIdx).join(" >> ").trim();
93
+ }
94
+ // ─────────────────────────────────────────────────────────────────
95
+ // RUNTIME SELECTOR DEDUPLICATION (FIX 2B)
96
+ //
97
+ // Removes duplicate consecutive `>>` segments from a runtime selector.
98
+ // Uses normalised comparison so minor whitespace / quote differences
99
+ // do not prevent deduplication.
100
+ //
101
+ // Example:
102
+ // "#modern-frame-container >> #modern-frame-container >> internal:control=enter-frame >> ..."
103
+ // → "#modern-frame-container >> internal:control=enter-frame >> ..."
104
+ // ─────────────────────────────────────────────────────────────────
105
+ function deduplicateRuntimeSelector(selector) {
106
+ const parts = selector.split(">>").map((s) => s.trim());
107
+ const deduped = [];
108
+ for (const part of parts) {
109
+ const norm = part
110
+ .replace(/['"]/g, "")
111
+ .toLowerCase()
112
+ .replace(/\s+/g, " ")
113
+ .trim();
114
+ const prevNorm = deduped.length > 0
115
+ ? deduped[deduped.length - 1]
116
+ .replace(/['"]/g, "")
117
+ .toLowerCase()
118
+ .replace(/\s+/g, " ")
119
+ .trim()
120
+ : null;
121
+ if (prevNorm !== null && norm === prevNorm) {
122
+ console.log(`[SelectorDedup] ✂️ Removed duplicate segment: "${part}"`);
123
+ continue;
124
+ }
125
+ deduped.push(part);
126
+ }
127
+ return deduped.join(" >> ");
128
+ }
129
+ function detectChain(lines, callerLine, oldLeafSelector) {
130
+ const escaped = oldLeafSelector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
131
+ const leafRegex = new RegExp(`\\.locator\\(["'\`]${escaped}["'\`]\\)`);
132
+ let leafLine = -1;
133
+ const scanStart = Math.max(0, callerLine - 15);
134
+ for (let i = callerLine; i >= scanStart; i--) {
135
+ if (leafRegex.test(lines[i])) {
136
+ leafLine = i;
137
+ break;
138
+ }
139
+ }
140
+ if (leafLine === -1)
141
+ return null;
142
+ let chainEnd = leafLine;
143
+ for (let i = leafLine + 1; i <= callerLine; i++) {
144
+ if (/^\s*\.filter\s*\(/.test(lines[i])) {
145
+ chainEnd = i;
146
+ }
147
+ else {
148
+ break;
149
+ }
150
+ }
151
+ let anchorSelector = "";
152
+ for (let i = leafLine - 1; i >= scanStart; i--) {
153
+ const anchorMatch = lines[i].match(/\.locator\(["'`]([^"'`]+)["'`]\)/);
154
+ if (anchorMatch) {
155
+ anchorSelector = anchorMatch[1];
156
+ break;
157
+ }
158
+ }
159
+ return { startLine: leafLine, endLine: chainEnd, anchorSelector };
160
+ }
161
+ function detectChainRoot(lines, callerLine, oldLeafSelector) {
162
+ const escaped = oldLeafSelector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
+ const leafPattern = new RegExp(`(?:locator|getByRole|getByLabel|getByText|getByTestId|getByPlaceholder)\\s*\\(\\s*["'\`]${escaped}["'\`]`);
164
+ let leafLine = -1;
165
+ const scanStart = Math.max(0, callerLine - 20);
166
+ for (let i = callerLine; i >= scanStart; i--) {
167
+ if (leafPattern.test(lines[i])) {
168
+ leafLine = i;
169
+ break;
170
+ }
171
+ }
172
+ if (leafLine === -1)
173
+ return null;
174
+ let chainEndLine = leafLine;
175
+ for (let i = leafLine + 1; i <= Math.min(lines.length - 1, leafLine + 10); i++) {
176
+ const trimmed = lines[i].trim();
177
+ if (/^\.(?:filter|first|last|nth)\s*\(/.test(trimmed)) {
178
+ chainEndLine = i;
179
+ }
180
+ else if (trimmed.startsWith(".")) {
181
+ break;
182
+ }
183
+ else {
184
+ break;
185
+ }
186
+ }
187
+ let rootLine = leafLine;
188
+ let rootReceiver = "page";
189
+ for (let i = leafLine - 1; i >= scanStart; i--) {
190
+ const trimmed = lines[i].trim();
191
+ const combined = lines[i];
192
+ const isChainContinuation = trimmed.startsWith(".") ||
193
+ combined.trimEnd().endsWith("(") ||
194
+ combined.trimEnd().endsWith(",");
195
+ if (isChainContinuation) {
196
+ rootLine = i;
197
+ continue;
198
+ }
199
+ const assignMatch = combined.match(/(?:const|let|var)\s+[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*(page|[a-zA-Z_$][a-zA-Z0-9_$]*)/);
200
+ if (assignMatch) {
201
+ rootReceiver = assignMatch[1];
202
+ rootLine = i;
203
+ break;
204
+ }
205
+ const pageMatch = combined.match(/^\s*((?:await\s+)?(?:page|[a-zA-Z_$][a-zA-Z0-9_$]*))\s*\.(?:locator|frameLocator|getBy)/);
206
+ if (pageMatch) {
207
+ rootReceiver = pageMatch[1].replace(/^await\s+/, "").trim();
208
+ rootLine = i;
209
+ break;
210
+ }
211
+ if (!isChainContinuation && i < leafLine - 1)
212
+ break;
213
+ }
214
+ return { rootLine, rootReceiver, leafLine, chainEndLine };
215
+ }
216
+ // ─────────────────────────────────────────────────────────────────
217
+ // TEMPLATE VARIABLE PRESERVATION
218
+ // ─────────────────────────────────────────────────────────────────
219
+ function updateTemplateLiteralPreservingVars(line, oldSelector, newSelector, domAttributeHint) {
220
+ if (!line.includes("`") || !line.includes("${"))
221
+ return null;
222
+ const newLeaf = newSelector.split(">>").pop().trim();
223
+ const varMatches = [...line.matchAll(/\$\{([^}]+)\}/g)];
224
+ if (varMatches.length === 0)
225
+ return null;
226
+ const varExpression = varMatches[0][1];
227
+ const oldAttrInTemplateMatch = line.match(/\[([a-zA-Z-]+)=["']?\$\{[^}]+\}([^"'\]`]*)["']?\]/);
228
+ const oldAttrName = oldAttrInTemplateMatch?.[1] ?? null;
229
+ const oldStaticSuffix = oldAttrInTemplateMatch?.[2] ?? null;
230
+ const newAttrMatch = newLeaf.match(/\[([a-zA-Z-]+)="([^"]*)"\]/);
231
+ const newAttrName = newAttrMatch?.[1] ?? null;
232
+ const newAttrValue = newAttrMatch?.[2] ?? null;
233
+ const oldAttrRuntimeMatch = oldSelector.match(/\[([a-zA-Z-]+)="([^"]*)"\]/);
234
+ const oldAttrRuntimeValue = oldAttrRuntimeMatch?.[2] ?? null;
235
+ let varRuntimeValue = null;
236
+ if (oldAttrRuntimeValue !== null && oldStaticSuffix !== null) {
237
+ if (oldStaticSuffix.length > 0) {
238
+ varRuntimeValue = oldAttrRuntimeValue.endsWith(oldStaticSuffix)
239
+ ? oldAttrRuntimeValue.slice(0, -oldStaticSuffix.length)
240
+ : oldAttrRuntimeValue.split("_")[0];
241
+ }
242
+ else {
243
+ const underscoreIdx = oldAttrRuntimeValue.indexOf("_");
244
+ varRuntimeValue =
245
+ underscoreIdx >= 0
246
+ ? oldAttrRuntimeValue.slice(0, underscoreIdx)
247
+ : oldAttrRuntimeValue;
248
+ }
249
+ }
250
+ if (oldAttrName &&
251
+ newAttrName &&
252
+ oldAttrName === newAttrName &&
253
+ newAttrValue) {
254
+ let newSuffix = "";
255
+ if (varRuntimeValue && newAttrValue.startsWith(varRuntimeValue)) {
256
+ newSuffix = newAttrValue.slice(varRuntimeValue.length);
257
+ }
258
+ else {
259
+ const lastUnderscoreIdx = newAttrValue.lastIndexOf("_");
260
+ newSuffix =
261
+ lastUnderscoreIdx >= 0 ? newAttrValue.slice(lastUnderscoreIdx) : "";
262
+ }
263
+ if (newSuffix === oldStaticSuffix)
264
+ return null;
265
+ const escapedOldSuffix = (oldStaticSuffix ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
266
+ const suffixPattern = new RegExp(`(\\$\\{[^}]+\\})${escapedOldSuffix}(["']?\\])`);
267
+ const updated = line.replace(suffixPattern, `$1${newSuffix}$2`);
268
+ return updated !== line ? updated : null;
269
+ }
270
+ if (oldAttrName && !newAttrName) {
271
+ let newSuffix = "";
272
+ if (domAttributeHint &&
273
+ varRuntimeValue &&
274
+ domAttributeHint.startsWith(varRuntimeValue)) {
275
+ newSuffix = domAttributeHint.slice(varRuntimeValue.length);
276
+ }
277
+ const reconstructedAttr = newSuffix.length > 0
278
+ ? `[${oldAttrName}="\${${varExpression}}${newSuffix}"]`
279
+ : `[${oldAttrName}="\${${varExpression}}"]`;
280
+ const oldAttrPattern = new RegExp(`\\[${oldAttrName.replace(/-/g, "\\-")}=["']?\\$\\{[^}]+\\}[^"'\\]\`]*["']?\\]`);
281
+ if (oldAttrPattern.test(line)) {
282
+ const updated = line.replace(oldAttrPattern, reconstructedAttr);
283
+ return updated !== line ? updated : null;
284
+ }
285
+ return null;
286
+ }
287
+ return null;
288
+ }
289
+ // ─────────────────────────────────────────────────────────────────
290
+ // MAIN CLASS
291
+ // ─────────────────────────────────────────────────────────────────
292
+ // ─────────────────────────────────────────────────────────────────
293
+ // GIT HELPER (used by branch strategy and autoCommit)
294
+ //
295
+ // Design goals:
296
+ // • Lean — no stash, no branch-switching, no complex state management.
297
+ // • Commit-based — write first (inPlace), then commit. If git fails,
298
+ // the fix is still on disk (silent inPlace fallback).
299
+ // • Detached HEAD aware — skips silently in CI environments that check
300
+ // out a specific SHA without a branch ref.
301
+ // ─────────────────────────────────────────────────────────────────
302
+ function runGitOperations(absoluteFilePath, createBranch, relativeFilePath) {
303
+ const filename = path.basename(relativeFilePath);
304
+ try {
305
+ // Bail out if HEAD is detached (CI SHA checkout, rebase mid-flight, etc.)
306
+ try {
307
+ (0, child_process_1.execSync)("git symbolic-ref HEAD", { stdio: "pipe" });
308
+ }
309
+ catch {
310
+ console.warn(`[SourceUpdater] ⚠️ Detached HEAD — git operations skipped (fix is saved to disk)`);
311
+ return;
312
+ }
313
+ if (createBranch) {
314
+ const branchName = `sela/heal-${Date.now()}`;
315
+ (0, child_process_1.execSync)(`git checkout -b ${branchName}`, { stdio: "pipe" });
316
+ console.log(`[SourceUpdater] 🌿 Created branch: ${branchName}`);
317
+ }
318
+ (0, child_process_1.execSync)(`git add "${absoluteFilePath}"`, { stdio: "pipe" });
319
+ (0, child_process_1.execSync)(`git commit -m "fix(sela): heal selector in ${filename}"`, {
320
+ stdio: "pipe",
321
+ });
322
+ console.log(`[SourceUpdater] ✅ Auto-committed fix in: ${filename}`);
323
+ }
324
+ catch (err) {
325
+ console.warn(`[SourceUpdater] ⚠️ Git operation failed: ${err.message} — fix written to disk (inPlace fallback)`);
326
+ }
327
+ }
328
+ class SourceUpdater {
329
+ // ─────────────────────────────────────────────────────────────
330
+ // PUBLIC ENTRY POINT
331
+ // Handles commentOnly short-circuit and post-write git operations,
332
+ // then delegates all selector-rewriting work to _coreUpdate().
333
+ // ─────────────────────────────────────────────────────────────
334
+ static update(caller, oldSelector, newSelector, domContext, dnaAttrHint, fullSelectorContext, aiSegments, contentChange, smartChainSegments, originalChainHint, updateStrategy, autoCommit) {
335
+ const resolvedPath = SourceUpdater.resolveFilePath(caller.filePath);
336
+ // ── commentOnly: write a TODO comment, skip all AST/regex work ──
337
+ if (updateStrategy === "commentOnly") {
338
+ if (!resolvedPath) {
339
+ return { success: false, reason: `File not found: ${caller.filePath}` };
340
+ }
341
+ const effective = deduplicateRuntimeSelector(newSelector);
342
+ return SourceUpdater.applyCommentOnly(caller, oldSelector, effective, resolvedPath);
343
+ }
344
+ // ── inPlace / branch: run the full rewriting pipeline ──────────
345
+ const result = SourceUpdater._coreUpdate(caller, oldSelector, newSelector, domContext, dnaAttrHint, fullSelectorContext, aiSegments, contentChange, smartChainSegments, originalChainHint);
346
+ // ── Post-write git operations (branch or autoCommit) ───────────
347
+ if (result.success &&
348
+ resolvedPath &&
349
+ (updateStrategy === "branch" || autoCommit)) {
350
+ runGitOperations(resolvedPath, updateStrategy === "branch", caller.filePath);
351
+ }
352
+ return result;
353
+ }
354
+ // ─────────────────────────────────────────────────────────────
355
+ // commentOnly helper — inserts a TODO comment above the failing
356
+ // call site and returns success:false so the engine knows the
357
+ // source was NOT rewritten (no retry suppression needed).
358
+ // ─────────────────────────────────────────────────────────────
359
+ static applyCommentOnly(caller, oldSelector, newSelector, filePath) {
360
+ try {
361
+ const raw = fs.readFileSync(filePath, "utf8");
362
+ const lines = raw.split(/\r?\n/);
363
+ const lineIdx = caller.line - 1; // 0-based
364
+ if (lineIdx < 0 || lineIdx >= lines.length) {
365
+ return { success: false, reason: "COMMENT_ONLY: line out of range" };
366
+ }
367
+ const indent = lines[lineIdx].match(/^(\s*)/)?.[1] ?? "";
368
+ const comment = `${indent}// TODO [Sela]: replace "${oldSelector}" with "${newSelector}"`;
369
+ lines.splice(lineIdx, 0, comment);
370
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
371
+ console.log(`[SourceUpdater] 💬 Comment-only: inserted TODO at line ${caller.line} in ${path.basename(filePath)}`);
372
+ return {
373
+ success: false,
374
+ reason: "COMMENT_ONLY: TODO inserted, source selector not modified",
375
+ healedLocatorString: newSelector,
376
+ };
377
+ }
378
+ catch (err) {
379
+ return {
380
+ success: false,
381
+ reason: `COMMENT_ONLY: write failed — ${err.message}`,
382
+ };
383
+ }
384
+ }
385
+ // ─────────────────────────────────────────────────────────────
386
+ // CORE UPDATE — Layered Healing (AST primary, Regex fallback)
387
+ // Called by update(); never call directly from outside the class.
388
+ // ─────────────────────────────────────────────────────────────
389
+ static _coreUpdate(caller, oldSelector, newSelector, domContext, dnaAttrHint, fullSelectorContext, aiSegments, contentChange, smartChainSegments, originalChainHint) {
390
+ const filePath = SourceUpdater.resolveFilePath(caller.filePath);
391
+ // ── FIX 2B: Deduplicate the runtime selector BEFORE passing it down ──
392
+ // This prevents the engine from writing double-frame selectors like
393
+ // "#modern-frame-container >> #modern-frame-container >> ..."
394
+ const deduplicatedNewSelector = deduplicateRuntimeSelector(newSelector);
395
+ if (deduplicatedNewSelector !== newSelector) {
396
+ console.log(`[SourceUpdater] ✂️ Runtime selector deduplicated:\n` +
397
+ ` before: "${newSelector}"\n` +
398
+ ` after: "${deduplicatedNewSelector}"`);
399
+ }
400
+ // Use the deduplicated selector for all subsequent operations
401
+ const effectiveNewSelector = deduplicatedNewSelector;
402
+ console.log(`[SourceUpdater] 📂 Attempting update in file: ${filePath}`);
403
+ console.log(`[SourceUpdater] 🔄 Request: "${oldSelector}" -> "${effectiveNewSelector}" (Line: ${caller.line})`);
404
+ if (smartChainSegments?.length) {
405
+ console.log(`[SourceUpdater] 🔗 smartChain: ${smartChainSegments.length} segments, ` +
406
+ `hint: ${originalChainHint?.length ?? 0} segments`);
407
+ }
408
+ if (contentChange) {
409
+ console.log(`[SourceUpdater] 📝 contentChange: "${contentChange.oldText}" → "${contentChange.newText}"`);
410
+ }
411
+ if (!filePath) {
412
+ console.error(`[SourceUpdater] ❌ File not found: ${caller.filePath}`);
413
+ return { success: false, reason: `File not found: ${caller.filePath}` };
414
+ }
415
+ // ── 1. AST Engine (Primary) ─────────────────────────────────────
416
+ try {
417
+ const astResult = astUpdater.update(caller, oldSelector, effectiveNewSelector, aiSegments, fullSelectorContext, contentChange, smartChainSegments, originalChainHint);
418
+ if (astResult.success) {
419
+ console.log(`[SourceUpdater] ✅ AST Strategy ${astResult.strategy} SUCCESS` +
420
+ (astResult.lineUpdated !== undefined
421
+ ? ` — line ${astResult.lineUpdated + 1}`
422
+ : ""));
423
+ return {
424
+ success: true,
425
+ reason: `AST[${astResult.strategy}]: ${astResult.reason}`,
426
+ lineUpdated: astResult.lineUpdated,
427
+ // Prefer the AST's own healedLocatorString; fall back to the deduplicated input.
428
+ healedLocatorString: astResult.healedLocatorString ?? effectiveNewSelector,
429
+ definitionSite: astResult.definitionSite,
430
+ blastRadius: astResult.blastRadius,
431
+ };
432
+ }
433
+ // Semantic guard means we identified a definition-site shape (IDENTIFIER /
434
+ // PROPERTY_ACCESS / TEMPLATE_LITERAL) but could not safely resolve the new
435
+ // value. Regex cannot do better and will corrupt the definition site.
436
+ if (astResult.strategy === "semantic-guard") {
437
+ console.warn(`[SourceUpdater] 🛑 Regex blocked — semantic guard fired. ` +
438
+ `Source unchanged. Reason: ${astResult.reason}`);
439
+ return { success: false, reason: astResult.reason };
440
+ }
441
+ console.log(`[SourceUpdater] ⚠️ AST engine exhausted (${astResult.reason}), falling back to Regex`);
442
+ }
443
+ catch (astError) {
444
+ console.warn(`[SourceUpdater] ⚠️ AST engine error: ${astError.message}. Falling back to Regex`);
445
+ }
446
+ // ── 2. Regex Fallback ──────────────────────────────────────────
447
+ console.log(`[SourceUpdater] 🔄 Using Regex fallback strategies...`);
448
+ const raw = fs.readFileSync(filePath, "utf8");
449
+ const lines = raw.split(/\r?\n/);
450
+ if (caller.line <= 0) {
451
+ console.log(`[SourceUpdater] ⚠️ line ${caller.line} invalid — running Regex Strategy G only`);
452
+ if (contentChange) {
453
+ const assertResult = SourceUpdater.strategyAssertionHealing(lines, 1, contentChange, true);
454
+ if (assertResult.success) {
455
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
456
+ console.log(`[SourceUpdater] ✅ Regex Strategy G (Assertion Healing) SUCCESS`);
457
+ return assertResult;
458
+ }
459
+ }
460
+ console.warn(`[SourceUpdater] ❌ ALL STRATEGIES FAILED for line-0 heal`);
461
+ return { success: false, reason: "invalid line number (≤0)" };
462
+ }
463
+ const callerLine = caller.line - 1; // 0-based
464
+ if (!lines[callerLine]) {
465
+ console.warn(`[SourceUpdater] 📍 Target line ${caller.line} is empty or out of bounds.`);
466
+ }
467
+ else {
468
+ console.log(`[SourceUpdater] 📍 Analyzing line ${caller.line}: "${lines[callerLine].trim()}"`);
469
+ }
470
+ const tryAssertionHealing = () => {
471
+ if (!contentChange)
472
+ return;
473
+ console.log(`[SourceUpdater] 🔍 Checking for assertion mismatches...`);
474
+ console.warn(`\n[Fixwright-Advice] 💡 To fix the assertion at line ${caller.line}, ` +
475
+ `change to: .toHaveText("${contentChange.newText}")\n`);
476
+ };
477
+ // ── Strategy 0: Layered Frame Healing ──────────────────────
478
+ if (aiSegments && aiSegments.length > 0 && fullSelectorContext) {
479
+ const layeredResult = SourceUpdater.strategyLayeredHealing(lines, callerLine, oldSelector, aiSegments, fullSelectorContext);
480
+ if (layeredResult.success) {
481
+ tryAssertionHealing();
482
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
483
+ console.log(`[SourceUpdater] ✅ Strategy 0 (Layered Healing) SUCCESS — line ${layeredResult.lineUpdated + 1}`);
484
+ return layeredResult;
485
+ }
486
+ }
487
+ // ── Strategy F: Chain Collapsing (Regex) ───────────────────
488
+ const chainCollapseResult = SourceUpdater.strategyChainCollapse_Regex(lines, callerLine, oldSelector, effectiveNewSelector);
489
+ if (chainCollapseResult.success) {
490
+ tryAssertionHealing();
491
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
492
+ console.log(`[SourceUpdater] ✅ Strategy F (Chain Collapse) SUCCESS`);
493
+ return chainCollapseResult;
494
+ }
495
+ // ── Strategy 1: Chain Collapse (original leaf-only) ────────
496
+ const chainResult = SourceUpdater.strategyChainCollapse(lines, callerLine, oldSelector, effectiveNewSelector);
497
+ if (chainResult.success) {
498
+ tryAssertionHealing();
499
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
500
+ console.log(`[SourceUpdater] ✅ Strategy 1 (Chain Collapse) SUCCESS`);
501
+ return chainResult;
502
+ }
503
+ // ── Strategy 2: Direct Literal ─────────────────────────────
504
+ const directResult = SourceUpdater.strategyDirectLiteral(lines, callerLine, oldSelector, effectiveNewSelector);
505
+ if (directResult.success) {
506
+ tryAssertionHealing();
507
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
508
+ console.log(`[SourceUpdater] ✅ Strategy 2 (Direct Literal) SUCCESS`);
509
+ return directResult;
510
+ }
511
+ // ── Strategy 3: Upstream Variable ──────────────────────────
512
+ const varResult = SourceUpdater.strategyUpstreamVariable(lines, callerLine, oldSelector, effectiveNewSelector);
513
+ if (varResult.success) {
514
+ tryAssertionHealing();
515
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
516
+ console.log(`[SourceUpdater] ✅ Strategy 3 (Upstream Variable) SUCCESS`);
517
+ return varResult;
518
+ }
519
+ // ── Strategy 4: Chained Dedup ──────────────────────────────
520
+ const chainDedupResult = SourceUpdater.strategyChainedLocator(lines, callerLine, oldSelector, effectiveNewSelector);
521
+ if (chainDedupResult.success) {
522
+ tryAssertionHealing();
523
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
524
+ console.log(`[SourceUpdater] ✅ Strategy 4 (Chained Dedup) SUCCESS`);
525
+ return chainDedupResult;
526
+ }
527
+ // ── Strategy 5: Function Return ────────────────────────────
528
+ const fnResult = SourceUpdater.strategyFunctionReturn(lines, callerLine, oldSelector, effectiveNewSelector, domContext, dnaAttrHint);
529
+ if (fnResult.success) {
530
+ tryAssertionHealing();
531
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
532
+ console.log(`[SourceUpdater] ✅ Strategy 5 (Function Return) SUCCESS`);
533
+ return fnResult;
534
+ }
535
+ // ── Strategy 6: Global Scan ────────────────────────────────
536
+ const scanResult = SourceUpdater.strategyGlobalScan(lines, oldSelector, effectiveNewSelector);
537
+ if (scanResult.success) {
538
+ tryAssertionHealing();
539
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
540
+ console.log(`[SourceUpdater] ✅ Strategy 6 (Global Scan) SUCCESS`);
541
+ return scanResult;
542
+ }
543
+ // ── Strategy G: Assertion Healing standalone ───────────────
544
+ if (contentChange) {
545
+ const assertResult = SourceUpdater.strategyAssertionHealing(lines, callerLine, contentChange);
546
+ if (assertResult.success) {
547
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
548
+ console.log(`[SourceUpdater] ✅ Strategy G (Assertion Healing) SUCCESS`);
549
+ return assertResult;
550
+ }
551
+ }
552
+ console.warn(`[SourceUpdater] ❌ ALL STRATEGIES FAILED for: "${oldSelector}"`);
553
+ return {
554
+ success: false,
555
+ reason: "No strategy matched (including AST and Regex Fallbacks)",
556
+ };
557
+ }
558
+ // ─────────────────────────────────────────────────────────────
559
+ // STRATEGY F (Regex) — Full Chain Collapsing
560
+ // ─────────────────────────────────────────────────────────────
561
+ static strategyChainCollapse_Regex(lines, callerLine, oldSelector, newSelector) {
562
+ console.log(`[Strategy-F] ⛓️ Starting Regex Chain Collapse...`);
563
+ const oldLeaf = oldSelector.split(">>").pop().trim();
564
+ const chainRootInfo = detectChainRoot(lines, callerLine, oldLeaf);
565
+ if (!chainRootInfo) {
566
+ return { success: false, reason: "no chain root detected" };
567
+ }
568
+ const { rootLine, rootReceiver, leafLine, chainEndLine } = chainRootInfo;
569
+ if (rootLine === leafLine && chainEndLine === leafLine) {
570
+ return { success: false, reason: "single-line call, not a chain" };
571
+ }
572
+ console.log(`[Strategy-F] 🔗 Chain detected: root=${rootLine + 1}, leaf=${leafLine + 1}, ` +
573
+ `end=${chainEndLine + 1}, receiver="${rootReceiver}"`);
574
+ const newLeaf = newSelector.split(">>").pop().trim();
575
+ const safeNewLeaf = newLeaf.replace(/"/g, "'");
576
+ const newCallText = `.locator("${safeNewLeaf}")`;
577
+ const indentMatch = lines[rootLine].match(/^(\s*)/);
578
+ const indent = indentMatch ? indentMatch[1] : "";
579
+ const newLine = `${indent}${rootReceiver}${newCallText}`;
580
+ if (rootLine !== chainEndLine) {
581
+ const linesToReplace = chainEndLine - rootLine + 1;
582
+ lines.splice(rootLine, linesToReplace, newLine);
583
+ console.log(`[Strategy-F] ✅ Multi-line chain collapsed (${linesToReplace} lines → 1): "${newLine.trim()}"`);
584
+ return {
585
+ success: true,
586
+ reason: `chain collapsed (${linesToReplace} lines → 1)`,
587
+ lineUpdated: rootLine,
588
+ };
589
+ }
590
+ else {
591
+ const originalLine = lines[rootLine];
592
+ const receiverPattern = new RegExp(`(${rootReceiver.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})` +
593
+ `(?:\\s*\\.\\s*(?:locator|getBy\\w+|frameLocator|filter|first|last|nth)\\s*\\([^)]*\\))+`);
594
+ if (receiverPattern.test(originalLine)) {
595
+ const replaced = originalLine.replace(receiverPattern, `${rootReceiver}${newCallText}`);
596
+ if (replaced !== originalLine) {
597
+ lines[rootLine] = replaced;
598
+ console.log(`[Strategy-F] ✅ Single-line chain collapsed: "${replaced.trim()}"`);
599
+ return {
600
+ success: true,
601
+ reason: "single-line chain collapsed",
602
+ lineUpdated: rootLine,
603
+ };
604
+ }
605
+ }
606
+ }
607
+ return { success: false, reason: "chain detected but could not replace" };
608
+ }
609
+ // ─────────────────────────────────────────────────────────────
610
+ // STRATEGY G (Regex) — Assertion Healing
611
+ // ─────────────────────────────────────────────────────────────
612
+ static strategyAssertionHealing(lines, callerLine, contentChange, wholeFile = false) {
613
+ const { oldText, newText } = contentChange;
614
+ console.log(`[Strategy-G] 🩹 Assertion Healing: "${oldText}" → "${newText}"`);
615
+ const TEXT_MATCHERS = [
616
+ "toHaveText",
617
+ "toContainText",
618
+ "toHaveValue",
619
+ "toHaveAttribute",
620
+ "toHaveLabel",
621
+ "toHaveTitle",
622
+ ];
623
+ const matcherPattern = new RegExp(`\\.(?:${TEXT_MATCHERS.join("|")})\\s*\\(`);
624
+ const searchStart = wholeFile ? 0 : Math.max(0, callerLine - 2);
625
+ const searchEnd = wholeFile
626
+ ? lines.length - 1
627
+ : Math.min(lines.length - 1, callerLine + 40);
628
+ let healedCount = 0;
629
+ let lastHealed;
630
+ for (let i = searchStart; i <= searchEnd; i++) {
631
+ const line = lines[i];
632
+ if (!matcherPattern.test(line))
633
+ continue;
634
+ for (const q of [`"`, `'`, "`"]) {
635
+ const escapedOld = oldText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
636
+ const valuePattern = new RegExp(`${q}([^${q}]*${escapedOld}[^${q}]*)${q}`);
637
+ const match = valuePattern.exec(line);
638
+ if (match) {
639
+ const oldValue = match[1];
640
+ const newValue = oldValue.replace(oldText, newText);
641
+ const safeNew = sanitizeForInjection(newValue, q);
642
+ const escaped = oldValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
643
+ const replaceRx = new RegExp(`${q}${escaped}${q}`);
644
+ const newLine = line.replace(replaceRx, `${q}${safeNew}${q}`);
645
+ if (newLine !== line) {
646
+ lines[i] = newLine;
647
+ healedCount++;
648
+ lastHealed = i;
649
+ console.log(`[Strategy-G] ✅ Healed assertion at line ${i + 1}: "${oldValue}" → "${newValue}"`);
650
+ break;
651
+ }
652
+ }
653
+ }
654
+ }
655
+ if (healedCount > 0) {
656
+ return {
657
+ success: true,
658
+ reason: `assertion healing: ${healedCount} assertion(s) updated`,
659
+ lineUpdated: lastHealed,
660
+ };
661
+ }
662
+ return { success: false, reason: "no assertions to heal" };
663
+ }
664
+ // ─────────────────────────────────────────────────────────────
665
+ // STRATEGY 0 — Layered Healing (Upstream Variable Tracing)
666
+ // ─────────────────────────────────────────────────────────────
667
+ static strategyLayeredHealing(lines, callerLine, oldElementSelector, aiSegments, fullSelectorContext) {
668
+ console.log(`[SourceUpdater] 🧠 Strategy 0: Starting Layered Healing...`);
669
+ const failureLine = lines[callerLine] ?? "";
670
+ const varUsageMatch = failureLine.match(/^\s*(?:await\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\.\s*locator\s*\(/);
671
+ const usedVarName = varUsageMatch?.[1];
672
+ if (usedVarName && usedVarName !== "page") {
673
+ const origin = SourceUpdater.findVariableOrigin(lines, usedVarName, callerLine);
674
+ if (origin) {
675
+ const frameSegments = aiSegments.filter((s) => s.type === "frame");
676
+ if (frameSegments.length === 0) {
677
+ lines[callerLine] = failureLine.replace(`${usedVarName}.locator`, `page.locator`);
678
+ return {
679
+ success: true,
680
+ reason: `Collapsed variable '${usedVarName}' to 'page'`,
681
+ lineUpdated: callerLine,
682
+ };
683
+ }
684
+ let newDeclaration = ` const ${usedVarName} = page`;
685
+ frameSegments.forEach((seg) => {
686
+ newDeclaration += `.frameLocator("${seg.selector}")`;
687
+ });
688
+ newDeclaration += ";";
689
+ const scanEnd = Math.min(lines.length - 1, origin.declarationLine + 5);
690
+ let endOfDeclaration = origin.declarationLine;
691
+ for (let i = origin.declarationLine; i <= scanEnd; i++) {
692
+ if (lines[i].includes(";")) {
693
+ endOfDeclaration = i;
694
+ break;
695
+ }
696
+ }
697
+ lines.splice(origin.declarationLine, endOfDeclaration - origin.declarationLine + 1, newDeclaration);
698
+ return {
699
+ success: true,
700
+ reason: `Reconstructed variable '${usedVarName}' with AI frame path`,
701
+ lineUpdated: origin.declarationLine,
702
+ };
703
+ }
704
+ }
705
+ return { success: false, reason: "no matches found in chain" };
706
+ }
707
+ static findVariableOrigin(lines, varName, startLine) {
708
+ const scanStart = Math.max(0, startLine - 30);
709
+ const declarationPattern = new RegExp(`^\\s*(?:const|let|var)\\s+${varName}\\s*=`);
710
+ for (let i = startLine - 1; i >= scanStart; i--) {
711
+ if (declarationPattern.test(lines[i])) {
712
+ return { varName, declarationLine: i, lineContent: lines[i] };
713
+ }
714
+ }
715
+ return null;
716
+ }
717
+ // ─────────────────────────────────────────────────────────────
718
+ // STRATEGY 1 — Chain Collapse (leaf-only precision replacement)
719
+ // ─────────────────────────────────────────────────────────────
720
+ static strategyChainCollapse(lines, callerLine, oldSelector, newSelector) {
721
+ const oldLeaf = oldSelector
722
+ .split(">>")
723
+ .pop()
724
+ .replace(/\.filter\([^)]*\)/g, "")
725
+ .trim();
726
+ const newLeaf = newSelector.split(">>").pop().trim();
727
+ const chain = detectChain(lines, callerLine, oldLeaf);
728
+ if (!chain)
729
+ return { success: false, reason: "no chain detected" };
730
+ const deduped = chain.anchorSelector
731
+ ? deduplicateSelector(newSelector, chain.anchorSelector)
732
+ : newLeaf;
733
+ const outerQuote = detectOuterQuote(lines[chain.startLine], oldLeaf) ?? `"`;
734
+ const safeDeduped = sanitizeForInjection(deduped, outerQuote);
735
+ const escapedOldLeaf = oldLeaf.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
736
+ const escapedQ = outerQuote.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
737
+ const leafLiteralRegex = new RegExp(`(\\.locator\\(${escapedQ})${escapedOldLeaf}(${escapedQ}\\))`);
738
+ if (leafLiteralRegex.test(lines[chain.startLine])) {
739
+ lines[chain.startLine] = lines[chain.startLine].replace(leafLiteralRegex, `$1${safeDeduped}$2`);
740
+ }
741
+ else {
742
+ const replaced = replaceStringLiteralInLine(lines[chain.startLine], oldLeaf, safeDeduped);
743
+ if (replaced === null)
744
+ return { success: false, reason: "chain leaf literal not found" };
745
+ lines[chain.startLine] = replaced;
746
+ }
747
+ if (chain.endLine > chain.startLine) {
748
+ for (let i = chain.endLine; i > chain.startLine; i--) {
749
+ if (/^\s*\.filter\s*\(/.test(lines[i]))
750
+ lines.splice(i, 1);
751
+ }
752
+ }
753
+ return {
754
+ success: true,
755
+ reason: `chain collapsed: '${oldLeaf}' → '${deduped}'`,
756
+ lineUpdated: chain.startLine,
757
+ };
758
+ }
759
+ // ─────────────────────────────────────────────────────────────
760
+ // STRATEGY 2 — Direct Literal
761
+ // ─────────────────────────────────────────────────────────────
762
+ static strategyDirectLiteral(lines, callerLine, oldSelector, newSelector) {
763
+ const windowSize = 10;
764
+ const start = Math.max(0, callerLine - windowSize);
765
+ const end = Math.min(lines.length - 1, callerLine + windowSize);
766
+ const lastOldPart = oldSelector.split(">>").pop().trim();
767
+ const lastNewPart = newSelector.split(">>").pop().trim();
768
+ for (let i = callerLine; i >= start; i--) {
769
+ const outerQ = detectOuterQuote(lines[i], lastOldPart);
770
+ if (!outerQ)
771
+ continue;
772
+ const safe = sanitizeForInjection(lastNewPart, outerQ);
773
+ const replaced = replaceStringLiteralInLine(lines[i], lastOldPart, safe);
774
+ if (replaced !== null) {
775
+ lines[i] = replaced;
776
+ return { success: true, reason: "direct literal", lineUpdated: i };
777
+ }
778
+ }
779
+ for (let i = callerLine + 1; i <= end; i++) {
780
+ const outerQ = detectOuterQuote(lines[i], lastOldPart);
781
+ if (!outerQ)
782
+ continue;
783
+ const safe = sanitizeForInjection(lastNewPart, outerQ);
784
+ const replaced = replaceStringLiteralInLine(lines[i], lastOldPart, safe);
785
+ if (replaced !== null) {
786
+ lines[i] = replaced;
787
+ return { success: true, reason: "direct literal", lineUpdated: i };
788
+ }
789
+ }
790
+ return { success: false, reason: "no direct literal" };
791
+ }
792
+ // ─────────────────────────────────────────────────────────────
793
+ // STRATEGY 3 — Upstream Variable/Const
794
+ // ─────────────────────────────────────────────────────────────
795
+ static strategyUpstreamVariable(lines, callerLine, oldSelector, newSelector) {
796
+ const callBlock = lines
797
+ .slice(Math.max(0, callerLine - 5), callerLine + 1)
798
+ .join("\n");
799
+ const identifierPattern = /\b([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\b/g;
800
+ const candidates = new Set();
801
+ let m;
802
+ while ((m = identifierPattern.exec(callBlock)) !== null)
803
+ candidates.add(m[1]);
804
+ const lastOldPart = oldSelector.split(">>").pop().trim();
805
+ const lastNewPart = newSelector.split(">>").pop().trim();
806
+ for (let i = 0; i < lines.length; i++) {
807
+ const line = lines[i];
808
+ const outerQ = detectOuterQuote(line, lastOldPart);
809
+ if (!outerQ)
810
+ continue;
811
+ const safe = sanitizeForInjection(lastNewPart, outerQ);
812
+ const replaced = replaceStringLiteralInLine(line, lastOldPart, safe);
813
+ if (replaced === null)
814
+ continue;
815
+ for (const ident of candidates) {
816
+ const baseName = ident.split(".")[0];
817
+ if (line.includes(baseName)) {
818
+ lines[i] = replaced;
819
+ return {
820
+ success: true,
821
+ reason: `upstream variable '${ident}'`,
822
+ lineUpdated: i,
823
+ };
824
+ }
825
+ }
826
+ }
827
+ return { success: false, reason: "no upstream variable" };
828
+ }
829
+ // ─────────────────────────────────────────────────────────────
830
+ // STRATEGY 4 — Chained Locator Dedup
831
+ // ─────────────────────────────────────────────────────────────
832
+ static strategyChainedLocator(lines, callerLine, oldSelector, newSelector) {
833
+ const blockStart = Math.max(0, callerLine - 10);
834
+ const chainBlock = lines.slice(blockStart, callerLine + 1).join("\n");
835
+ const deduped = deduplicateSelector(newSelector, oldSelector + "\n" + chainBlock);
836
+ if (deduped === newSelector)
837
+ return { success: false, reason: "no duplication detected" };
838
+ const lastOldPart = oldSelector.split(">>").pop().trim();
839
+ for (let i = callerLine; i >= blockStart; i--) {
840
+ const outerQ = detectOuterQuote(lines[i], lastOldPart);
841
+ if (!outerQ)
842
+ continue;
843
+ const safe = sanitizeForInjection(deduped, outerQ);
844
+ const replaced = replaceStringLiteralInLine(lines[i], lastOldPart, safe);
845
+ if (replaced !== null) {
846
+ lines[i] = replaced;
847
+ return {
848
+ success: true,
849
+ reason: `chained dedup: '${newSelector}' → '${deduped}'`,
850
+ lineUpdated: i,
851
+ };
852
+ }
853
+ }
854
+ return { success: false, reason: "dedup found but literal not replaced" };
855
+ }
856
+ // ─────────────────────────────────────────────────────────────
857
+ // STRATEGY 5 — Function/Template with Variable Preservation
858
+ // ─────────────────────────────────────────────────────────────
859
+ static strategyFunctionReturn(lines, callerLine, oldSelector, newSelector, domContext, dnaAttrHint) {
860
+ const callBlock = lines
861
+ .slice(Math.max(0, callerLine - 8), callerLine + 1)
862
+ .join("\n");
863
+ const fnCallPattern = /\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
864
+ const fnNames = new Set();
865
+ let m;
866
+ const playwrightBuiltins = new Set([
867
+ "locator",
868
+ "fill",
869
+ "click",
870
+ "check",
871
+ "uncheck",
872
+ "hover",
873
+ "dblclick",
874
+ "focus",
875
+ "waitFor",
876
+ "isVisible",
877
+ "getAttribute",
878
+ "innerText",
879
+ "innerHTML",
880
+ "textContent",
881
+ "page",
882
+ "filter",
883
+ "expect",
884
+ "selectOption",
885
+ "frameLocator",
886
+ ]);
887
+ while ((m = fnCallPattern.exec(callBlock)) !== null) {
888
+ if (!playwrightBuiltins.has(m[1]))
889
+ fnNames.add(m[1]);
890
+ }
891
+ if (fnNames.size === 0)
892
+ return { success: false, reason: "no function calls found near site" };
893
+ const newLeaf = newSelector.split(">>").pop().trim();
894
+ const oldSelectorAttrMatch = oldSelector.match(/\[([a-zA-Z][a-zA-Z0-9-]*)=/);
895
+ const expectedAttrName = oldSelectorAttrMatch?.[1] ?? null;
896
+ const resolvedAttrHint = dnaAttrHint ??
897
+ SourceUpdater.extractAttrValueFromDom(domContext ?? "", oldSelector) ??
898
+ undefined;
899
+ for (const fnName of fnNames) {
900
+ const defPattern = new RegExp(`(?:const|let|var|function)\\s+${fnName}\\b`);
901
+ for (let i = 0; i < lines.length; i++) {
902
+ if (!defPattern.test(lines[i]))
903
+ continue;
904
+ for (let j = i; j < Math.min(lines.length, i + 8); j++) {
905
+ const line = lines[j];
906
+ if (line.includes("`") && line.includes("${")) {
907
+ if (expectedAttrName && !line.includes(expectedAttrName))
908
+ continue;
909
+ if (!expectedAttrName)
910
+ continue;
911
+ const preserved = updateTemplateLiteralPreservingVars(line, oldSelector, newSelector, resolvedAttrHint);
912
+ if (preserved !== null && preserved !== line) {
913
+ lines[j] = preserved;
914
+ return {
915
+ success: true,
916
+ reason: `function template updated (vars preserved)`,
917
+ lineUpdated: j,
918
+ };
919
+ }
920
+ continue;
921
+ }
922
+ const lastOldPart = oldSelector.split(">>").pop().trim();
923
+ const outerQ = detectOuterQuote(line, lastOldPart);
924
+ if (!outerQ)
925
+ continue;
926
+ const safe = sanitizeForInjection(newLeaf, outerQ);
927
+ const replaced = replaceStringLiteralInLine(line, lastOldPart, safe);
928
+ if (replaced !== null) {
929
+ lines[j] = replaced;
930
+ return {
931
+ success: true,
932
+ reason: `function literal replaced`,
933
+ lineUpdated: j,
934
+ };
935
+ }
936
+ }
937
+ }
938
+ }
939
+ return { success: false, reason: "function strategy exhausted" };
940
+ }
941
+ // ─────────────────────────────────────────────────────────────
942
+ // STRATEGY 6 — Global Scan
943
+ // ─────────────────────────────────────────────────────────────
944
+ static strategyGlobalScan(lines, oldSelector, newSelector) {
945
+ const lastOldPart = oldSelector.split(">>").pop().trim();
946
+ const lastNewPart = newSelector.split(">>").pop().trim();
947
+ for (let i = 0; i < lines.length; i++) {
948
+ const deduped = deduplicateSelector(newSelector, lines[i]);
949
+ const effectiveNew = deduped !== newSelector ? deduped : lastNewPart;
950
+ const outerQ = detectOuterQuote(lines[i], lastOldPart);
951
+ if (!outerQ)
952
+ continue;
953
+ const safe = sanitizeForInjection(effectiveNew, outerQ);
954
+ const replaced = replaceStringLiteralInLine(lines[i], lastOldPart, safe);
955
+ if (replaced !== null) {
956
+ lines[i] = replaced;
957
+ return { success: true, reason: "global scan", lineUpdated: i };
958
+ }
959
+ }
960
+ return { success: false, reason: "not found anywhere in file" };
961
+ }
962
+ // ─────────────────────────────────────────────────────────────
963
+ // UTILITIES
964
+ // ─────────────────────────────────────────────────────────────
965
+ static extractAttrValueFromDom(domContext, oldSelector) {
966
+ const attrMatch = oldSelector.match(/\[([a-zA-Z-]+)=/);
967
+ if (!attrMatch)
968
+ return null;
969
+ const attrName = attrMatch[1];
970
+ const domValueMatch = domContext.match(new RegExp(`${attrName.replace(/-/g, "\\-")}=["']([^"']*)["']`));
971
+ return domValueMatch?.[1] ?? null;
972
+ }
973
+ static resolveFilePath(raw) {
974
+ let clean = raw
975
+ .replace(/^.*?at\s+/, "")
976
+ .replace(/:\d+:\d+.*$/, "")
977
+ .trim();
978
+ if (!path.isAbsolute(clean))
979
+ clean = path.resolve(process.cwd(), clean);
980
+ return fs.existsSync(clean) ? clean : null;
981
+ }
982
+ static updateArgument(caller, action, oldArgument, newArgument) {
983
+ const filePath = SourceUpdater.resolveFilePath(caller.filePath);
984
+ if (!filePath)
985
+ return { success: false, reason: `File not found: ${caller.filePath}` };
986
+ const raw = fs.readFileSync(filePath, "utf8");
987
+ const lines = raw.split(/\r?\n/);
988
+ const callerLine = caller.line - 1;
989
+ const windowSize = 5;
990
+ const start = Math.max(0, callerLine - windowSize);
991
+ const end = Math.min(lines.length - 1, callerLine + windowSize);
992
+ const escapedAction = action.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
993
+ const escapedOldArg = oldArgument.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
994
+ for (const q of [`"`, `'`]) {
995
+ const pattern = new RegExp(`(\\.${escapedAction}\\(${q})${escapedOldArg}(${q})`);
996
+ for (let i = callerLine; i >= start; i--) {
997
+ if (pattern.test(lines[i])) {
998
+ const safeNew = sanitizeForInjection(newArgument, q);
999
+ lines[i] = lines[i].replace(pattern, `$1${safeNew}$2`);
1000
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
1001
+ return { success: true, reason: "argument updated", lineUpdated: i };
1002
+ }
1003
+ }
1004
+ for (let i = callerLine + 1; i <= end; i++) {
1005
+ if (pattern.test(lines[i])) {
1006
+ const safeNew = sanitizeForInjection(newArgument, q);
1007
+ lines[i] = lines[i].replace(pattern, `$1${safeNew}$2`);
1008
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
1009
+ return { success: true, reason: "argument updated", lineUpdated: i };
1010
+ }
1011
+ }
1012
+ }
1013
+ console.warn(`[SourceUpdater] ❌ Argument "${oldArgument}" not found near line ${caller.line}`);
1014
+ return { success: false, reason: "argument not found" };
1015
+ }
1016
+ /** Flush buffered advisories to console. Call after Playwright prints its test result. */
1017
+ static flushAdvisories() {
1018
+ astUpdater.flushAdvisories();
1019
+ }
1020
+ }
1021
+ exports.SourceUpdater = SourceUpdater;