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.
- package/README.md +93 -0
- package/bin/sela.js +3 -0
- package/dist/cli/ErrorHandler.d.ts +10 -0
- package/dist/cli/ErrorHandler.d.ts.map +1 -0
- package/dist/cli/ErrorHandler.js +70 -0
- package/dist/cli/commands/bulk.d.ts +3 -0
- package/dist/cli/commands/bulk.d.ts.map +1 -0
- package/dist/cli/commands/bulk.js +140 -0
- package/dist/cli/commands/find.d.ts +3 -0
- package/dist/cli/commands/find.d.ts.map +1 -0
- package/dist/cli/commands/find.js +51 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +133 -0
- package/dist/cli/commands/list.d.ts +3 -0
- package/dist/cli/commands/list.d.ts.map +1 -0
- package/dist/cli/commands/list.js +56 -0
- package/dist/cli/commands/refactor.d.ts +3 -0
- package/dist/cli/commands/refactor.d.ts.map +1 -0
- package/dist/cli/commands/refactor.js +30 -0
- package/dist/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +51 -0
- package/dist/cli/commands/sync.d.ts +3 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +123 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/ui/DnaTable.d.ts +3 -0
- package/dist/cli/ui/DnaTable.d.ts.map +1 -0
- package/dist/cli/ui/DnaTable.js +70 -0
- package/dist/cli/ui/LocatorPicker.d.ts +8 -0
- package/dist/cli/ui/LocatorPicker.d.ts.map +1 -0
- package/dist/cli/ui/LocatorPicker.js +33 -0
- package/dist/cli/ui/ProgressReporter.d.ts +11 -0
- package/dist/cli/ui/ProgressReporter.d.ts.map +1 -0
- package/dist/cli/ui/ProgressReporter.js +33 -0
- package/dist/cli/ui/RefactorWizard.d.ts +26 -0
- package/dist/cli/ui/RefactorWizard.d.ts.map +1 -0
- package/dist/cli/ui/RefactorWizard.js +269 -0
- package/dist/config/ConfigLoader.d.ts +15 -0
- package/dist/config/ConfigLoader.d.ts.map +1 -0
- package/dist/config/ConfigLoader.js +174 -0
- package/dist/config/SelaConfig.d.ts +67 -0
- package/dist/config/SelaConfig.d.ts.map +1 -0
- package/dist/config/SelaConfig.js +57 -0
- package/dist/config/defineConfig.d.ts +3 -0
- package/dist/config/defineConfig.d.ts.map +1 -0
- package/dist/config/defineConfig.js +9 -0
- package/dist/engine/FixwrightEngine.d.ts +24 -0
- package/dist/engine/FixwrightEngine.d.ts.map +1 -0
- package/dist/engine/FixwrightEngine.js +403 -0
- package/dist/engine/HealingRegistry.d.ts +40 -0
- package/dist/engine/HealingRegistry.d.ts.map +1 -0
- package/dist/engine/HealingRegistry.js +98 -0
- package/dist/engine/singleton.d.ts +3 -0
- package/dist/engine/singleton.d.ts.map +1 -0
- package/dist/engine/singleton.js +5 -0
- package/dist/fixtures/expectProxy.d.ts +12 -0
- package/dist/fixtures/expectProxy.d.ts.map +1 -0
- package/dist/fixtures/expectProxy.js +228 -0
- package/dist/fixtures/index.d.ts +6 -0
- package/dist/fixtures/index.d.ts.map +1 -0
- package/dist/fixtures/index.js +688 -0
- package/dist/fixtures/moduleExpect.d.ts +2 -0
- package/dist/fixtures/moduleExpect.d.ts.map +1 -0
- package/dist/fixtures/moduleExpect.js +46 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/services/ASTSourceUpdater.d.ts +79 -0
- package/dist/services/ASTSourceUpdater.d.ts.map +1 -0
- package/dist/services/ASTSourceUpdater.js +3177 -0
- package/dist/services/ArgumentTypeAnalyzer.d.ts +26 -0
- package/dist/services/ArgumentTypeAnalyzer.d.ts.map +1 -0
- package/dist/services/ArgumentTypeAnalyzer.js +92 -0
- package/dist/services/BlastRadiusAnalyzer.d.ts +15 -0
- package/dist/services/BlastRadiusAnalyzer.d.ts.map +1 -0
- package/dist/services/BlastRadiusAnalyzer.js +103 -0
- package/dist/services/ChainValidator.d.ts +76 -0
- package/dist/services/ChainValidator.d.ts.map +1 -0
- package/dist/services/ChainValidator.js +569 -0
- package/dist/services/CrossFileHealer.d.ts +6 -0
- package/dist/services/CrossFileHealer.d.ts.map +1 -0
- package/dist/services/CrossFileHealer.js +134 -0
- package/dist/services/DefinitionTracer.d.ts +41 -0
- package/dist/services/DefinitionTracer.d.ts.map +1 -0
- package/dist/services/DefinitionTracer.js +350 -0
- package/dist/services/DnaEditorService.d.ts +31 -0
- package/dist/services/DnaEditorService.d.ts.map +1 -0
- package/dist/services/DnaEditorService.js +198 -0
- package/dist/services/DnaIndexService.d.ts +24 -0
- package/dist/services/DnaIndexService.d.ts.map +1 -0
- package/dist/services/DnaIndexService.js +131 -0
- package/dist/services/HealingAdvisory.d.ts +22 -0
- package/dist/services/HealingAdvisory.d.ts.map +1 -0
- package/dist/services/HealingAdvisory.js +42 -0
- package/dist/services/HealthReportService.d.ts +10 -0
- package/dist/services/HealthReportService.d.ts.map +1 -0
- package/dist/services/HealthReportService.js +84 -0
- package/dist/services/InitializerUpdater.d.ts +16 -0
- package/dist/services/InitializerUpdater.d.ts.map +1 -0
- package/dist/services/InitializerUpdater.js +37 -0
- package/dist/services/IntentAuditor.d.ts +39 -0
- package/dist/services/IntentAuditor.d.ts.map +1 -0
- package/dist/services/IntentAuditor.js +302 -0
- package/dist/services/LLMService.d.ts +100 -0
- package/dist/services/LLMService.d.ts.map +1 -0
- package/dist/services/LLMService.js +439 -0
- package/dist/services/SafetyGuard.d.ts +65 -0
- package/dist/services/SafetyGuard.d.ts.map +1 -0
- package/dist/services/SafetyGuard.js +524 -0
- package/dist/services/SnapshotService.d.ts +11 -0
- package/dist/services/SnapshotService.d.ts.map +1 -0
- package/dist/services/SnapshotService.js +349 -0
- package/dist/services/SourceLinkService.d.ts +26 -0
- package/dist/services/SourceLinkService.d.ts.map +1 -0
- package/dist/services/SourceLinkService.js +156 -0
- package/dist/services/SourceUpdater.d.ts +45 -0
- package/dist/services/SourceUpdater.d.ts.map +1 -0
- package/dist/services/SourceUpdater.js +1021 -0
- package/dist/services/TemplateDiffService.d.ts +25 -0
- package/dist/services/TemplateDiffService.d.ts.map +1 -0
- package/dist/services/TemplateDiffService.js +331 -0
- package/dist/services/types.d.ts +59 -0
- package/dist/services/types.d.ts.map +1 -0
- package/dist/services/types.js +2 -0
- package/dist/storage/SnapshotManager.d.ts +5 -0
- package/dist/storage/SnapshotManager.d.ts.map +1 -0
- package/dist/storage/SnapshotManager.js +31 -0
- package/dist/types/index.d.ts +95 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +7 -0
- package/dist/utils/DOMUtils.d.ts +33 -0
- package/dist/utils/DOMUtils.d.ts.map +1 -0
- package/dist/utils/DOMUtils.js +179 -0
- package/dist/utils/StackUtils.d.ts +11 -0
- package/dist/utils/StackUtils.d.ts.map +1 -0
- package/dist/utils/StackUtils.js +120 -0
- package/dist/vendor/enquirer.d.ts +33 -0
- package/dist/vendor/enquirer.d.ts.map +1 -0
- package/dist/vendor/enquirer.js +11 -0
- package/package.json +67 -0
|
@@ -0,0 +1,3177 @@
|
|
|
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.ASTSourceUpdater = void 0;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const ts_morph_1 = require("ts-morph");
|
|
40
|
+
const HealingRegistry_1 = require("../engine/HealingRegistry");
|
|
41
|
+
const ArgumentTypeAnalyzer_1 = require("./ArgumentTypeAnalyzer");
|
|
42
|
+
const BlastRadiusAnalyzer_1 = require("./BlastRadiusAnalyzer");
|
|
43
|
+
const DefinitionTracer_1 = require("./DefinitionTracer");
|
|
44
|
+
const TemplateDiffService_1 = require("./TemplateDiffService");
|
|
45
|
+
const InitializerUpdater_1 = require("./InitializerUpdater");
|
|
46
|
+
const CrossFileHealer_1 = require("./CrossFileHealer");
|
|
47
|
+
const HealingAdvisory_1 = require("./HealingAdvisory");
|
|
48
|
+
const ChainValidator_1 = require("./ChainValidator");
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
50
|
+
// FIX #1 — normalizeSelector utility
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
52
|
+
function normalizeSelector(s) {
|
|
53
|
+
return s
|
|
54
|
+
.replace(/['"``]/g, "")
|
|
55
|
+
.replace(/\s*>>\s*internal:control=[^\s>]*/g, "")
|
|
56
|
+
.replace(/\s+/g, "")
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
61
|
+
// SCOPE DETECTION ← THE CORE FIX
|
|
62
|
+
//
|
|
63
|
+
// Determines whether a CallExpression node lives inside the
|
|
64
|
+
// VariableDeclaration initializer of a named variable.
|
|
65
|
+
//
|
|
66
|
+
// Returns the VariableDeclaration if the call is inside the initializer
|
|
67
|
+
// of "varName", or null otherwise.
|
|
68
|
+
//
|
|
69
|
+
// Why this matters:
|
|
70
|
+
// const shadowBtn = shadowBtn.getByRole(…) ← CIRCULAR — must be caught
|
|
71
|
+
// const shadowBtn = page.getByRole(…) ← CORRECT
|
|
72
|
+
//
|
|
73
|
+
// We walk UP the ancestor chain from the call node looking for a
|
|
74
|
+
// VariableDeclaration. If we find one whose name matches varName AND
|
|
75
|
+
// the call is inside its initializer subtree, we return it.
|
|
76
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
77
|
+
function getEnclosingVariableDeclaration(node) {
|
|
78
|
+
let current = node.getParent();
|
|
79
|
+
while (current) {
|
|
80
|
+
if (ts_morph_1.Node.isVariableDeclaration(current)) {
|
|
81
|
+
return current;
|
|
82
|
+
}
|
|
83
|
+
// Stop climbing past statement boundaries — we only want the immediate
|
|
84
|
+
// enclosing declaration, not a declaration somewhere further up the tree.
|
|
85
|
+
if (ts_morph_1.Node.isBlock(current) ||
|
|
86
|
+
ts_morph_1.Node.isSourceFile(current) ||
|
|
87
|
+
ts_morph_1.Node.isArrowFunction(current) ||
|
|
88
|
+
ts_morph_1.Node.isFunctionDeclaration(current) ||
|
|
89
|
+
ts_morph_1.Node.isFunctionExpression(current)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
current = current.getParent();
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Returns true when `callNode` is inside the initializer of the variable
|
|
98
|
+
* named `varName`. This is the exact condition that would produce a
|
|
99
|
+
* circular reference if we used `varName` as the receiver.
|
|
100
|
+
*/
|
|
101
|
+
function isInsideOwnInitializer(callNode, varName) {
|
|
102
|
+
const enclosingDecl = getEnclosingVariableDeclaration(callNode);
|
|
103
|
+
if (!enclosingDecl)
|
|
104
|
+
return false;
|
|
105
|
+
return enclosingDecl.getName() === varName;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Given a forced receiver (e.g. "shadowBtn") and the call node being
|
|
109
|
+
* replaced, decide the safe receiver to use:
|
|
110
|
+
*
|
|
111
|
+
* • If the call is inside shadowBtn's own initializer → use rootReceiver
|
|
112
|
+
* (typically "page") to avoid circular reference.
|
|
113
|
+
* • Otherwise → use the forced receiver as-is.
|
|
114
|
+
*
|
|
115
|
+
* `rootReceiver` is the leftmost identifier in the original chain
|
|
116
|
+
* (resolved by climbToChainTop), which is always a stable root like "page".
|
|
117
|
+
*/
|
|
118
|
+
function resolveSafeReceiver(proposedReceiver, rootReceiver, callNode) {
|
|
119
|
+
if (isInsideOwnInitializer(callNode, proposedReceiver)) {
|
|
120
|
+
console.log(`[ScopeGuard] 🔒 Circular reference prevented: ` +
|
|
121
|
+
`"${proposedReceiver}" is being initialized — ` +
|
|
122
|
+
`falling back to root receiver "${rootReceiver}"`);
|
|
123
|
+
return rootReceiver;
|
|
124
|
+
}
|
|
125
|
+
return proposedReceiver;
|
|
126
|
+
}
|
|
127
|
+
function parseInternalSelector(selector) {
|
|
128
|
+
const normalized = selector.replace(/\s*i\s*$/, "").trim();
|
|
129
|
+
const roleMatch = normalized.match(/^internal:role=([a-zA-Z]+)(?:\[name="([^"]+)"(?:i)?\])?/);
|
|
130
|
+
if (roleMatch) {
|
|
131
|
+
const result = {
|
|
132
|
+
method: "getByRole",
|
|
133
|
+
primaryArg: roleMatch[1],
|
|
134
|
+
isSemantic: true,
|
|
135
|
+
};
|
|
136
|
+
if (roleMatch[2])
|
|
137
|
+
result.options = { name: roleMatch[2] };
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
const labelMatch = normalized.match(/^internal:label="([^"]+)"$/);
|
|
141
|
+
if (labelMatch) {
|
|
142
|
+
return {
|
|
143
|
+
method: "getByLabel",
|
|
144
|
+
primaryArg: labelMatch[1],
|
|
145
|
+
isSemantic: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const labelNoQuote = normalized.match(/^internal:label=([^[]+)$/);
|
|
149
|
+
if (labelNoQuote) {
|
|
150
|
+
return {
|
|
151
|
+
method: "getByLabel",
|
|
152
|
+
primaryArg: labelNoQuote[1].trim(),
|
|
153
|
+
isSemantic: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const textMatch = normalized.match(/^internal:text="([^"]+)"(i)?$/);
|
|
157
|
+
if (textMatch) {
|
|
158
|
+
const result = {
|
|
159
|
+
method: "getByText",
|
|
160
|
+
primaryArg: textMatch[1],
|
|
161
|
+
isSemantic: true,
|
|
162
|
+
};
|
|
163
|
+
if (!textMatch[2])
|
|
164
|
+
result.options = { exact: true };
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
const placeholderMatch = normalized.match(/^internal:attr=\[placeholder="([^"]+)"\]$/);
|
|
168
|
+
if (placeholderMatch) {
|
|
169
|
+
return {
|
|
170
|
+
method: "getByPlaceholder",
|
|
171
|
+
primaryArg: placeholderMatch[1],
|
|
172
|
+
isSemantic: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const altMatch = normalized.match(/^internal:attr=\[alt="([^"]+)"\]$/);
|
|
176
|
+
if (altMatch) {
|
|
177
|
+
return {
|
|
178
|
+
method: "getByAltText",
|
|
179
|
+
primaryArg: altMatch[1],
|
|
180
|
+
isSemantic: true,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const titleMatch = normalized.match(/^internal:attr=\[title="([^"]+)"\]$/);
|
|
184
|
+
if (titleMatch) {
|
|
185
|
+
return {
|
|
186
|
+
method: "getByTitle",
|
|
187
|
+
primaryArg: titleMatch[1],
|
|
188
|
+
isSemantic: true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const testIdMatch = normalized.match(/^\[data-testid="([^"]+)"\]$/);
|
|
192
|
+
if (testIdMatch) {
|
|
193
|
+
return {
|
|
194
|
+
method: "getByTestId",
|
|
195
|
+
primaryArg: testIdMatch[1],
|
|
196
|
+
isSemantic: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { method: "locator", primaryArg: selector, isSemantic: false };
|
|
200
|
+
}
|
|
201
|
+
function buildCallText(receiver, sem) {
|
|
202
|
+
const safeArg = sem.primaryArg.replace(/"/g, "'");
|
|
203
|
+
switch (sem.method) {
|
|
204
|
+
case "getByRole":
|
|
205
|
+
if (sem.options?.name) {
|
|
206
|
+
return `${receiver}.getByRole("${safeArg}", { name: "${sem.options.name}" })`;
|
|
207
|
+
}
|
|
208
|
+
return `${receiver}.getByRole("${safeArg}")`;
|
|
209
|
+
case "getByLabel":
|
|
210
|
+
return `${receiver}.getByLabel("${safeArg}")`;
|
|
211
|
+
case "getByPlaceholder":
|
|
212
|
+
return `${receiver}.getByPlaceholder("${safeArg}")`;
|
|
213
|
+
case "getByTestId":
|
|
214
|
+
return `${receiver}.getByTestId("${safeArg}")`;
|
|
215
|
+
case "getByText":
|
|
216
|
+
if (sem.options?.exact)
|
|
217
|
+
return `${receiver}.getByText("${safeArg}", { exact: true })`;
|
|
218
|
+
return `${receiver}.getByText("${safeArg}")`;
|
|
219
|
+
case "getByAltText":
|
|
220
|
+
return `${receiver}.getByAltText("${safeArg}")`;
|
|
221
|
+
case "getByTitle":
|
|
222
|
+
return `${receiver}.getByTitle("${safeArg}")`;
|
|
223
|
+
case "locator":
|
|
224
|
+
default:
|
|
225
|
+
return `${receiver}.locator("${sem.primaryArg.replace(/"/g, "'")}")`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function extractSemanticFromCall(callExpr) {
|
|
229
|
+
const expr = callExpr.getExpression();
|
|
230
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
231
|
+
return null;
|
|
232
|
+
const method = expr.getName();
|
|
233
|
+
if (!ALL_SELECTOR_METHODS.has(method))
|
|
234
|
+
return null;
|
|
235
|
+
const args = callExpr.getArguments();
|
|
236
|
+
if (args.length === 0)
|
|
237
|
+
return null;
|
|
238
|
+
const firstArg = args[0];
|
|
239
|
+
let primaryArg = null;
|
|
240
|
+
if (ts_morph_1.Node.isStringLiteral(firstArg)) {
|
|
241
|
+
primaryArg = firstArg.getLiteralValue();
|
|
242
|
+
}
|
|
243
|
+
else if (ts_morph_1.Node.isNoSubstitutionTemplateLiteral(firstArg)) {
|
|
244
|
+
primaryArg = firstArg.getLiteralValue();
|
|
245
|
+
}
|
|
246
|
+
if (primaryArg === null)
|
|
247
|
+
return null;
|
|
248
|
+
const options = {};
|
|
249
|
+
if (args.length >= 2 && ts_morph_1.Node.isObjectLiteralExpression(args[1])) {
|
|
250
|
+
for (const prop of args[1].getProperties()) {
|
|
251
|
+
if (ts_morph_1.Node.isPropertyAssignment(prop)) {
|
|
252
|
+
const key = prop.getName();
|
|
253
|
+
const val = prop.getInitializer();
|
|
254
|
+
if (val && ts_morph_1.Node.isStringLiteral(val))
|
|
255
|
+
options[key] = val.getLiteralValue();
|
|
256
|
+
else if (val && ts_morph_1.Node.isTrueLiteral(val))
|
|
257
|
+
options[key] = true;
|
|
258
|
+
else if (val && ts_morph_1.Node.isFalseLiteral(val))
|
|
259
|
+
options[key] = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const isSemantic = method !== "locator" && method !== "frameLocator";
|
|
264
|
+
return {
|
|
265
|
+
method,
|
|
266
|
+
primaryArg,
|
|
267
|
+
options: Object.keys(options).length > 0 ? options : undefined,
|
|
268
|
+
isSemantic,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function semanticMatch(callSemantic, oldSelector, allowRelated = false) {
|
|
272
|
+
const leaf = oldSelector.split(">>").pop().trim();
|
|
273
|
+
const normalizedLeaf = leaf.replace(/\s*i\s*$/, "").trim();
|
|
274
|
+
const oldSem = parseInternalSelector(normalizedLeaf);
|
|
275
|
+
if (callSemantic.method === "getByRole" && oldSem.method === "getByRole") {
|
|
276
|
+
if (!isRoleCompatible(callSemantic.primaryArg, oldSem.primaryArg))
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (callSemantic.method === oldSem.method &&
|
|
280
|
+
callSemantic.primaryArg === oldSem.primaryArg) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (allowRelated) {
|
|
284
|
+
const textualMethods = new Set([
|
|
285
|
+
"getByLabel",
|
|
286
|
+
"getByPlaceholder",
|
|
287
|
+
"getByText",
|
|
288
|
+
"getByAltText",
|
|
289
|
+
"getByTitle",
|
|
290
|
+
]);
|
|
291
|
+
if (textualMethods.has(callSemantic.method) &&
|
|
292
|
+
textualMethods.has(oldSem.method)) {
|
|
293
|
+
const a = callSemantic.primaryArg.toLowerCase();
|
|
294
|
+
const b = oldSem.primaryArg.toLowerCase();
|
|
295
|
+
if (a === b || a.includes(b) || b.includes(a))
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (oldSem.isSemantic && callSemantic.isSemantic) {
|
|
300
|
+
const oldLeafArg = oldSem.primaryArg.toLowerCase();
|
|
301
|
+
const callArg = callSemantic.primaryArg.toLowerCase();
|
|
302
|
+
if (oldLeafArg === callArg)
|
|
303
|
+
return true;
|
|
304
|
+
if (callArg.includes(oldLeafArg) || oldLeafArg.includes(callArg))
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
if (allowRelated) {
|
|
308
|
+
const textualMethods = new Set([
|
|
309
|
+
"getByLabel",
|
|
310
|
+
"getByPlaceholder",
|
|
311
|
+
"getByText",
|
|
312
|
+
"getByAltText",
|
|
313
|
+
"getByTitle",
|
|
314
|
+
]);
|
|
315
|
+
if (textualMethods.has(callSemantic.method) &&
|
|
316
|
+
textualMethods.has(oldSem.method) &&
|
|
317
|
+
callSemantic.method !== oldSem.method) {
|
|
318
|
+
const lower_a = callSemantic.primaryArg.toLowerCase();
|
|
319
|
+
const lower_b = oldSem.primaryArg.toLowerCase();
|
|
320
|
+
const CONCEPT_DICTIONARY = new Map([
|
|
321
|
+
["email", new Set(["email", "e-mail", "mail", "inbox", "@"])],
|
|
322
|
+
[
|
|
323
|
+
"password",
|
|
324
|
+
new Set(["password", "passwd", "passcode", "pin", "secret"]),
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
"username",
|
|
328
|
+
new Set(["username", "user name", "login", "account name", "handle"]),
|
|
329
|
+
],
|
|
330
|
+
["phone", new Set(["phone", "mobile", "cell", "tel", "telephone"])],
|
|
331
|
+
["address", new Set(["address", "street", "city", "zip", "postal"])],
|
|
332
|
+
["name", new Set(["first name", "last name", "full name", "surname"])],
|
|
333
|
+
["search", new Set(["search", "query", "find", "filter", "keyword"])],
|
|
334
|
+
[
|
|
335
|
+
"submit",
|
|
336
|
+
new Set(["submit", "send", "confirm", "execute", "proceed", "save"]),
|
|
337
|
+
],
|
|
338
|
+
["cancel", new Set(["cancel", "dismiss", "close", "abort", "back"])],
|
|
339
|
+
["date", new Set(["date", "day", "month", "year", "time"])],
|
|
340
|
+
]);
|
|
341
|
+
for (const [, keywords] of CONCEPT_DICTIONARY) {
|
|
342
|
+
let aMatch = false, bMatch = false;
|
|
343
|
+
for (const kw of keywords) {
|
|
344
|
+
if (lower_a.includes(kw))
|
|
345
|
+
aMatch = true;
|
|
346
|
+
if (lower_b.includes(kw))
|
|
347
|
+
bMatch = true;
|
|
348
|
+
}
|
|
349
|
+
if (aMatch && bMatch)
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const oldLeaf = oldSelector.split(">>").pop().trim();
|
|
355
|
+
const oldLeafSem = parseInternalSelector(oldLeaf);
|
|
356
|
+
if (callSemantic.method === oldLeafSem.method &&
|
|
357
|
+
callSemantic.primaryArg === oldLeafSem.primaryArg) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
function absoluteNodeMatch(callExpr, oldSelector) {
|
|
363
|
+
const callSem = extractSemanticFromCall(callExpr);
|
|
364
|
+
if (!callSem)
|
|
365
|
+
return false;
|
|
366
|
+
const leaf = oldSelector.split(">>").pop().trim();
|
|
367
|
+
const normalizedLeaf = leaf.replace(/\s*i\s*$/, "").trim();
|
|
368
|
+
const oldSem = parseInternalSelector(normalizedLeaf);
|
|
369
|
+
if (callSem.method === oldSem.method &&
|
|
370
|
+
callSem.primaryArg === oldSem.primaryArg) {
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
if (callSem.method === oldSem.method && oldSem.method !== "locator") {
|
|
374
|
+
const a = normalize(callSem.primaryArg);
|
|
375
|
+
const b = normalize(oldSem.primaryArg);
|
|
376
|
+
if (a === b || a.includes(b) || b.includes(a))
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (callSem.method !== "locator" &&
|
|
380
|
+
callSem.method !== "frameLocator" &&
|
|
381
|
+
oldSem.method !== "locator" &&
|
|
382
|
+
oldSem.method !== "frameLocator") {
|
|
383
|
+
const a = normalize(callSem.primaryArg);
|
|
384
|
+
const b = normalize(oldSem.primaryArg);
|
|
385
|
+
const minLen = Math.min(a.length, b.length);
|
|
386
|
+
if (minLen >= 4 && (a.includes(b) || b.includes(a)))
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (oldSem.method === "locator" && callSem.method !== "locator") {
|
|
390
|
+
const rawValueMatch = normalizedLeaf.match(/"([^"]{3,})"/);
|
|
391
|
+
if (rawValueMatch) {
|
|
392
|
+
const rawValue = normalize(rawValueMatch[1]);
|
|
393
|
+
const astArg = normalize(callSem.primaryArg);
|
|
394
|
+
if (astArg.includes(rawValue) || rawValue.includes(astArg))
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
function normalize(s) {
|
|
401
|
+
return s
|
|
402
|
+
.toLowerCase()
|
|
403
|
+
.replace(/^[-_\s]+|[-_\s]+$/g, "")
|
|
404
|
+
.trim();
|
|
405
|
+
}
|
|
406
|
+
function isRoleCompatible(roleA, roleB) {
|
|
407
|
+
const interactive = new Set([
|
|
408
|
+
"button",
|
|
409
|
+
"textbox",
|
|
410
|
+
"checkbox",
|
|
411
|
+
"radio",
|
|
412
|
+
"combobox",
|
|
413
|
+
"listbox",
|
|
414
|
+
"link",
|
|
415
|
+
"menuitem",
|
|
416
|
+
]);
|
|
417
|
+
const structural = new Set([
|
|
418
|
+
"heading",
|
|
419
|
+
"banner",
|
|
420
|
+
"main",
|
|
421
|
+
"navigation",
|
|
422
|
+
"complementary",
|
|
423
|
+
"contentinfo",
|
|
424
|
+
]);
|
|
425
|
+
if (interactive.has(roleA.toLowerCase()) &&
|
|
426
|
+
structural.has(roleB.toLowerCase()))
|
|
427
|
+
return false;
|
|
428
|
+
if (structural.has(roleA.toLowerCase()) &&
|
|
429
|
+
interactive.has(roleB.toLowerCase()))
|
|
430
|
+
return false;
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
434
|
+
// METHOD SETS
|
|
435
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
436
|
+
const SEMANTIC_METHODS = new Set([
|
|
437
|
+
"getByRole",
|
|
438
|
+
"getByLabel",
|
|
439
|
+
"getByText",
|
|
440
|
+
"getByTestId",
|
|
441
|
+
"getByPlaceholder",
|
|
442
|
+
"getByAltText",
|
|
443
|
+
"getByTitle",
|
|
444
|
+
]);
|
|
445
|
+
const ALL_SELECTOR_METHODS = new Set([
|
|
446
|
+
"locator",
|
|
447
|
+
"frameLocator",
|
|
448
|
+
...SEMANTIC_METHODS,
|
|
449
|
+
]);
|
|
450
|
+
const CHAIN_CONTINUATION_METHODS = new Set([
|
|
451
|
+
...ALL_SELECTOR_METHODS,
|
|
452
|
+
"filter",
|
|
453
|
+
"first",
|
|
454
|
+
"last",
|
|
455
|
+
"nth",
|
|
456
|
+
]);
|
|
457
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
458
|
+
// MethodMapper
|
|
459
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
460
|
+
class MethodMapper {
|
|
461
|
+
static build(receiver, seg) {
|
|
462
|
+
const q = (s) => `"${s.replace(/"/g, "'")}"`;
|
|
463
|
+
switch (seg.type) {
|
|
464
|
+
case "locator": {
|
|
465
|
+
const opts = MethodMapper.buildLocatorOptions(seg);
|
|
466
|
+
return opts
|
|
467
|
+
? `${receiver}.locator(${q(seg.selector)}, { ${opts} })`
|
|
468
|
+
: `${receiver}.locator(${q(seg.selector)})`;
|
|
469
|
+
}
|
|
470
|
+
case "frameLocator":
|
|
471
|
+
return `${receiver}.frameLocator(${q(seg.selector)})`;
|
|
472
|
+
case "getByRole": {
|
|
473
|
+
const opts = [];
|
|
474
|
+
if (seg.name !== undefined && seg.name !== "")
|
|
475
|
+
opts.push(`name: ${q(seg.name)}`);
|
|
476
|
+
if (seg.exact !== undefined)
|
|
477
|
+
opts.push(`exact: ${seg.exact}`);
|
|
478
|
+
if (seg.level !== undefined)
|
|
479
|
+
opts.push(`level: ${seg.level}`);
|
|
480
|
+
if (seg.checked !== undefined)
|
|
481
|
+
opts.push(`checked: ${seg.checked}`);
|
|
482
|
+
if (seg.pressed !== undefined)
|
|
483
|
+
opts.push(`pressed: ${seg.pressed}`);
|
|
484
|
+
if (seg.expanded !== undefined)
|
|
485
|
+
opts.push(`expanded: ${seg.expanded}`);
|
|
486
|
+
if (seg.selected !== undefined)
|
|
487
|
+
opts.push(`selected: ${seg.selected}`);
|
|
488
|
+
if (seg.disabled !== undefined)
|
|
489
|
+
opts.push(`disabled: ${seg.disabled}`);
|
|
490
|
+
const optStr = opts.length > 0 ? `, { ${opts.join(", ")} }` : "";
|
|
491
|
+
return `${receiver}.getByRole(${q(seg.role)}${optStr})`;
|
|
492
|
+
}
|
|
493
|
+
case "getByLabel":
|
|
494
|
+
return `${receiver}.getByLabel(${q(seg.text)}${seg.exact !== undefined ? `, { exact: ${seg.exact} }` : ""})`;
|
|
495
|
+
case "getByText":
|
|
496
|
+
return `${receiver}.getByText(${q(seg.text)}${seg.exact !== undefined ? `, { exact: ${seg.exact} }` : ""})`;
|
|
497
|
+
case "getByPlaceholder":
|
|
498
|
+
return `${receiver}.getByPlaceholder(${q(seg.text)}${seg.exact !== undefined ? `, { exact: ${seg.exact} }` : ""})`;
|
|
499
|
+
case "getByAltText":
|
|
500
|
+
return `${receiver}.getByAltText(${q(seg.text)}${seg.exact !== undefined ? `, { exact: ${seg.exact} }` : ""})`;
|
|
501
|
+
case "getByTitle":
|
|
502
|
+
return `${receiver}.getByTitle(${q(seg.text)}${seg.exact !== undefined ? `, { exact: ${seg.exact} }` : ""})`;
|
|
503
|
+
case "getByTestId":
|
|
504
|
+
return `${receiver}.getByTestId(${q(seg.testId)})`;
|
|
505
|
+
case "filter":
|
|
506
|
+
return `${receiver}.filter(${MethodMapper.buildFilterOptions(seg)})`;
|
|
507
|
+
case "first":
|
|
508
|
+
return `${receiver}.first()`;
|
|
509
|
+
case "last":
|
|
510
|
+
return `${receiver}.last()`;
|
|
511
|
+
case "nth":
|
|
512
|
+
return `${receiver}.nth(${seg.index})`;
|
|
513
|
+
default:
|
|
514
|
+
console.warn(`[MethodMapper] Unknown segment type: ${seg.type}`);
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
static buildFilterOptions(seg) {
|
|
519
|
+
const opts = [];
|
|
520
|
+
if (seg.hasText !== undefined) {
|
|
521
|
+
const ht = seg.hasText;
|
|
522
|
+
if (typeof ht === "string")
|
|
523
|
+
opts.push(`hasText: "${ht.replace(/"/g, "'")}"`);
|
|
524
|
+
else if (ht instanceof RegExp)
|
|
525
|
+
opts.push(`hasText: ${ht.toString()}`);
|
|
526
|
+
}
|
|
527
|
+
if (seg.hasNotText !== undefined) {
|
|
528
|
+
const hnt = seg.hasNotText;
|
|
529
|
+
if (typeof hnt === "string")
|
|
530
|
+
opts.push(`hasNotText: "${hnt.replace(/"/g, "'")}"`);
|
|
531
|
+
}
|
|
532
|
+
return opts.length === 0 ? "{}" : `{ ${opts.join(", ")} }`;
|
|
533
|
+
}
|
|
534
|
+
static buildLocatorOptions(seg) {
|
|
535
|
+
const opts = [];
|
|
536
|
+
if (seg.hasText !== undefined) {
|
|
537
|
+
opts.push(`hasText: "${seg.hasText.replace(/"/g, "'")}"`);
|
|
538
|
+
}
|
|
539
|
+
if (seg.hasNotText !== undefined) {
|
|
540
|
+
opts.push(`hasNotText: "${seg.hasNotText.replace(/"/g, "'")}"`);
|
|
541
|
+
}
|
|
542
|
+
return opts.length > 0 ? opts.join(", ") : null;
|
|
543
|
+
}
|
|
544
|
+
static buildChain(receiver, segments, originalText) {
|
|
545
|
+
if (segments.length === 0)
|
|
546
|
+
return null;
|
|
547
|
+
const baseIndent = MethodMapper.detectIndent(originalText);
|
|
548
|
+
const innerIndent = baseIndent + " ";
|
|
549
|
+
const calls = [];
|
|
550
|
+
let currentReceiver = receiver;
|
|
551
|
+
for (const seg of segments) {
|
|
552
|
+
const callText = MethodMapper.build(currentReceiver, seg);
|
|
553
|
+
if (callText === null) {
|
|
554
|
+
console.warn(`[MethodMapper] Cannot build segment type "${seg.type}" — aborting chain`);
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
const methodPart = callText.slice(currentReceiver.length);
|
|
558
|
+
calls.push(methodPart);
|
|
559
|
+
currentReceiver = callText;
|
|
560
|
+
}
|
|
561
|
+
if (segments.length >= 3) {
|
|
562
|
+
return [receiver, ...calls.map((c) => `${innerIndent}${c}`)].join("\n");
|
|
563
|
+
}
|
|
564
|
+
return receiver + calls.join("");
|
|
565
|
+
}
|
|
566
|
+
static detectIndent(text) {
|
|
567
|
+
if (!text)
|
|
568
|
+
return "";
|
|
569
|
+
const match = text.match(/^(\s+)/);
|
|
570
|
+
return match ? match[1] : "";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
574
|
+
// CHAIN DEDUPLICATION & VARIABLE RESOLUTION ENGINE
|
|
575
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
576
|
+
function normalizeSegmentSelector(s) {
|
|
577
|
+
return normalizeSelector(s);
|
|
578
|
+
}
|
|
579
|
+
function segmentsStructurallyEqual(ctx, ai) {
|
|
580
|
+
if (ctx.type !== ai.type)
|
|
581
|
+
return false;
|
|
582
|
+
if ("selector" in ctx && "selector" in ai) {
|
|
583
|
+
const a = normalizeSelector(ctx.selector);
|
|
584
|
+
const b = normalizeSelector(ai.selector);
|
|
585
|
+
return a === b || a.includes(b) || b.includes(a);
|
|
586
|
+
}
|
|
587
|
+
if ("text" in ctx && "text" in ai) {
|
|
588
|
+
const a = normalizeSelector(ctx.text);
|
|
589
|
+
const b = normalizeSelector(ai.text);
|
|
590
|
+
return a === b;
|
|
591
|
+
}
|
|
592
|
+
if ("role" in ctx && "role" in ai) {
|
|
593
|
+
return ctx.role === ai.role;
|
|
594
|
+
}
|
|
595
|
+
if ("testId" in ctx && "testId" in ai) {
|
|
596
|
+
return (normalizeSelector(ctx.testId) ===
|
|
597
|
+
normalizeSelector(ai.testId));
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
function deduplicateChainSegments(aiSegments, contextSegments) {
|
|
602
|
+
if (contextSegments.length === 0)
|
|
603
|
+
return { deduped: aiSegments, stripped: 0 };
|
|
604
|
+
let overlap = 0;
|
|
605
|
+
const maxCheck = Math.min(contextSegments.length, aiSegments.length);
|
|
606
|
+
for (let i = 0; i < maxCheck; i++) {
|
|
607
|
+
if (segmentsStructurallyEqual(contextSegments[i], aiSegments[i])) {
|
|
608
|
+
overlap++;
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (overlap === 0)
|
|
615
|
+
return { deduped: aiSegments, stripped: 0 };
|
|
616
|
+
console.log(`[ChainDedup] ✂️ Stripping ${overlap}/${contextSegments.length} duplicate leading ` +
|
|
617
|
+
`segment(s) — context already encodes them`);
|
|
618
|
+
return { deduped: aiSegments.slice(overlap), stripped: overlap };
|
|
619
|
+
}
|
|
620
|
+
const PAGE_ROOT_NAMES = new Set([
|
|
621
|
+
"page",
|
|
622
|
+
"context",
|
|
623
|
+
"browser",
|
|
624
|
+
"playwright",
|
|
625
|
+
"request",
|
|
626
|
+
]);
|
|
627
|
+
function semToSegment(sem) {
|
|
628
|
+
switch (sem.method) {
|
|
629
|
+
case "frameLocator":
|
|
630
|
+
return { type: "frameLocator", selector: sem.primaryArg };
|
|
631
|
+
case "locator":
|
|
632
|
+
return { type: "locator", selector: sem.primaryArg };
|
|
633
|
+
case "getByText":
|
|
634
|
+
return {
|
|
635
|
+
type: "getByText",
|
|
636
|
+
text: sem.primaryArg,
|
|
637
|
+
exact: sem.options?.exact,
|
|
638
|
+
};
|
|
639
|
+
case "getByLabel":
|
|
640
|
+
return {
|
|
641
|
+
type: "getByLabel",
|
|
642
|
+
text: sem.primaryArg,
|
|
643
|
+
exact: sem.options?.exact,
|
|
644
|
+
};
|
|
645
|
+
case "getByPlaceholder":
|
|
646
|
+
return { type: "getByPlaceholder", text: sem.primaryArg };
|
|
647
|
+
case "getByRole":
|
|
648
|
+
return {
|
|
649
|
+
type: "getByRole",
|
|
650
|
+
role: sem.primaryArg,
|
|
651
|
+
name: sem.options?.name,
|
|
652
|
+
};
|
|
653
|
+
case "getByTestId":
|
|
654
|
+
return { type: "getByTestId", testId: sem.primaryArg };
|
|
655
|
+
case "getByAltText":
|
|
656
|
+
return { type: "getByAltText", text: sem.primaryArg };
|
|
657
|
+
case "getByTitle":
|
|
658
|
+
return { type: "getByTitle", text: sem.primaryArg };
|
|
659
|
+
default:
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function resolveVariableToSegments(sourceFile, varName, beforeLine, depth = 0) {
|
|
664
|
+
const empty = {
|
|
665
|
+
segments: [],
|
|
666
|
+
rootName: varName,
|
|
667
|
+
varName,
|
|
668
|
+
bestReceiverVar: null,
|
|
669
|
+
};
|
|
670
|
+
if (depth > 10)
|
|
671
|
+
return empty;
|
|
672
|
+
if (PAGE_ROOT_NAMES.has(varName)) {
|
|
673
|
+
return { segments: [], rootName: varName, varName, bestReceiverVar: null };
|
|
674
|
+
}
|
|
675
|
+
const decl = jumpToDefinitionInFile(sourceFile, varName, beforeLine + 5);
|
|
676
|
+
if (!decl)
|
|
677
|
+
return empty;
|
|
678
|
+
const init = decl.getInitializer();
|
|
679
|
+
if (!init || !ts_morph_1.Node.isCallExpression(init))
|
|
680
|
+
return empty;
|
|
681
|
+
const calls = collectChainCalls(init);
|
|
682
|
+
calls.reverse();
|
|
683
|
+
const segments = [];
|
|
684
|
+
let rootName = varName;
|
|
685
|
+
let receiverVarForRecursion = null;
|
|
686
|
+
for (let ci = 0; ci < calls.length; ci++) {
|
|
687
|
+
const call = calls[ci];
|
|
688
|
+
const sem = extractSemanticFromCall(call);
|
|
689
|
+
if (!sem)
|
|
690
|
+
continue;
|
|
691
|
+
if (ci === 0) {
|
|
692
|
+
const expr = call.getExpression();
|
|
693
|
+
const receiverNode = ts_morph_1.Node.isPropertyAccessExpression(expr)
|
|
694
|
+
? expr.getExpression()
|
|
695
|
+
: null;
|
|
696
|
+
const receiverName = receiverNode
|
|
697
|
+
?.getText()
|
|
698
|
+
.replace(/^await\s+/, "")
|
|
699
|
+
.trim() ?? null;
|
|
700
|
+
if (receiverName && !PAGE_ROOT_NAMES.has(receiverName)) {
|
|
701
|
+
receiverVarForRecursion = receiverName;
|
|
702
|
+
const parentRes = resolveVariableToSegments(sourceFile, receiverName, decl.getStartLineNumber(), depth + 1);
|
|
703
|
+
segments.push(...parentRes.segments);
|
|
704
|
+
rootName = parentRes.rootName;
|
|
705
|
+
}
|
|
706
|
+
else if (receiverName) {
|
|
707
|
+
rootName = receiverName;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const seg = semToSegment(sem);
|
|
711
|
+
if (seg)
|
|
712
|
+
segments.push(seg);
|
|
713
|
+
}
|
|
714
|
+
console.log(`[VAR-RESOLVE] Variable: ${varName}, Resolved Segments: ${JSON.stringify(segments)}`);
|
|
715
|
+
console.log(`[VarResolve] 🔍 '${varName}' → root:'${rootName}' ` +
|
|
716
|
+
`via:'${receiverVarForRecursion ?? "direct"}' ` +
|
|
717
|
+
`segs:[${segments.map((s) => s.type).join(",")}]`);
|
|
718
|
+
return {
|
|
719
|
+
segments,
|
|
720
|
+
rootName,
|
|
721
|
+
varName,
|
|
722
|
+
bestReceiverVar: receiverVarForRecursion,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
726
|
+
// NODE TRANSFORMATION STRATEGY — Scope-Aware, Zero-Redundancy
|
|
727
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
728
|
+
class NodeTransformationStrategy {
|
|
729
|
+
chainValidator;
|
|
730
|
+
constructor(chainValidator) {
|
|
731
|
+
this.chainValidator = chainValidator;
|
|
732
|
+
}
|
|
733
|
+
replaceLocatorNode(sourceFile, caller, oldSelector, newSegments, originalChainHint, suspectIdentifier, searchWindow) {
|
|
734
|
+
console.log(`[NodeTransform] 🔬 Absolute node search for: "${oldSelector}"`);
|
|
735
|
+
// ── Phase A: Variable-Aware Strip-and-Force ─────────────────────
|
|
736
|
+
//
|
|
737
|
+
// Two-pass deduplication, now with scope-aware receiver selection.
|
|
738
|
+
// After dedup we defer the actual receiver safety check to Phase E,
|
|
739
|
+
// where we have the concrete call node and can call resolveSafeReceiver().
|
|
740
|
+
let workingSegments = newSegments;
|
|
741
|
+
let forcedReceiver = null;
|
|
742
|
+
// Track the AST root (page / root var) so Phase E can fall back to it
|
|
743
|
+
// if the proposed receiver turns out to be the variable being initialized.
|
|
744
|
+
let resolvedRootReceiver = "page";
|
|
745
|
+
const tryDedupAgainst = (varName, label) => {
|
|
746
|
+
if (!varName || PAGE_ROOT_NAMES.has(varName))
|
|
747
|
+
return null;
|
|
748
|
+
const resolution = resolveVariableToSegments(sourceFile, varName, caller.line);
|
|
749
|
+
if (resolution.segments.length === 0)
|
|
750
|
+
return null;
|
|
751
|
+
console.log(`[DEDUP-CHECK] (${label}) AI Segments: ${JSON.stringify(workingSegments)} ` +
|
|
752
|
+
`vs Resolved Var Segments for '${varName}': ${JSON.stringify(resolution.segments)}`);
|
|
753
|
+
const { deduped, stripped } = deduplicateChainSegments(workingSegments, resolution.segments);
|
|
754
|
+
if (stripped === 0)
|
|
755
|
+
return null;
|
|
756
|
+
// Determine the best intermediate receiver
|
|
757
|
+
let receiver = varName;
|
|
758
|
+
if (resolution.bestReceiverVar) {
|
|
759
|
+
const intermRes = resolveVariableToSegments(sourceFile, resolution.bestReceiverVar, caller.line);
|
|
760
|
+
const { stripped: interStripped } = deduplicateChainSegments(newSegments, intermRes.segments);
|
|
761
|
+
if (interStripped === stripped) {
|
|
762
|
+
receiver = resolution.bestReceiverVar;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
console.log(`[NodeTransform] ✂️ Strip-and-Force (${label}): stripped=${stripped}, ` +
|
|
766
|
+
`receiver="${receiver}" ` +
|
|
767
|
+
`remaining=[${deduped.map((s) => s.type).join(",")}]`);
|
|
768
|
+
return { receiver, deduped, rootName: resolution.rootName };
|
|
769
|
+
};
|
|
770
|
+
// Pass 1: suspectIdentifier
|
|
771
|
+
if (suspectIdentifier && !PAGE_ROOT_NAMES.has(suspectIdentifier)) {
|
|
772
|
+
const r = tryDedupAgainst(suspectIdentifier, "suspectId");
|
|
773
|
+
if (r) {
|
|
774
|
+
forcedReceiver = r.receiver;
|
|
775
|
+
workingSegments = r.deduped;
|
|
776
|
+
resolvedRootReceiver = r.rootName;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Pass 2: AST root receiver (catches cases where astRoot != suspectIdentifier)
|
|
780
|
+
let discoveredAstRoot = null;
|
|
781
|
+
if (!forcedReceiver) {
|
|
782
|
+
discoveredAstRoot = this.discoverAstRootReceiver(sourceFile, oldSelector, searchWindow, suspectIdentifier, caller.line);
|
|
783
|
+
if (discoveredAstRoot && !PAGE_ROOT_NAMES.has(discoveredAstRoot)) {
|
|
784
|
+
const r = tryDedupAgainst(discoveredAstRoot, "astRoot");
|
|
785
|
+
if (r) {
|
|
786
|
+
forcedReceiver = r.receiver;
|
|
787
|
+
workingSegments = r.deduped;
|
|
788
|
+
resolvedRootReceiver = r.rootName;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// ── Pass 3: Type-Collision Guard ─────────────────────────────────
|
|
793
|
+
// When AI's first segment TYPE matches the receiver variable's defining type
|
|
794
|
+
// but with a different selector, the AI is replacing the receiver context.
|
|
795
|
+
// Strategy: update the receiver variable's definition, then use remaining
|
|
796
|
+
// segments with the receiver var — preserving developer's variable structure.
|
|
797
|
+
if (!forcedReceiver && discoveredAstRoot && !PAGE_ROOT_NAMES.has(discoveredAstRoot)) {
|
|
798
|
+
const typeCheckRes = resolveVariableToSegments(sourceFile, discoveredAstRoot, caller.line);
|
|
799
|
+
if (typeCheckRes.segments.length > 0 && workingSegments.length > 0) {
|
|
800
|
+
const receiverDefType = typeCheckRes.segments[typeCheckRes.segments.length - 1].type;
|
|
801
|
+
const aiLeadType = workingSegments[0].type;
|
|
802
|
+
if (receiverDefType === aiLeadType) {
|
|
803
|
+
const pageRoot = typeCheckRes.rootName || "page";
|
|
804
|
+
const newReceiverSelector = workingSegments[0].selector;
|
|
805
|
+
const remainingSegments = workingSegments.slice(1);
|
|
806
|
+
console.log(`[NodeTransform] 🚨 TypeCollision: "${discoveredAstRoot}" IS ${receiverDefType}, ` +
|
|
807
|
+
`AI leads with ${aiLeadType}`);
|
|
808
|
+
if (newReceiverSelector && remainingSegments.length > 0) {
|
|
809
|
+
const defUpdated = this.updateReceiverDefinition(sourceFile, discoveredAstRoot, receiverDefType, newReceiverSelector, caller.line);
|
|
810
|
+
if (defUpdated) {
|
|
811
|
+
console.log(`[NodeTransform] ✅ TypeCollision: updated "${discoveredAstRoot}" def → ` +
|
|
812
|
+
`${receiverDefType}("${newReceiverSelector}")`);
|
|
813
|
+
forcedReceiver = discoveredAstRoot;
|
|
814
|
+
workingSegments = remainingSegments;
|
|
815
|
+
resolvedRootReceiver = pageRoot;
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
console.log(`[NodeTransform] ⚠️ TypeCollision: def update failed → page root fallback`);
|
|
819
|
+
forcedReceiver = pageRoot;
|
|
820
|
+
resolvedRootReceiver = pageRoot;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
forcedReceiver = pageRoot;
|
|
825
|
+
resolvedRootReceiver = pageRoot;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// ── Phase B: Semantic-First Enforcement ─────────────────────────
|
|
831
|
+
workingSegments = workingSegments.map((seg) => {
|
|
832
|
+
if (seg.type !== "locator")
|
|
833
|
+
return seg;
|
|
834
|
+
const m = seg.selector.match(/^:has-text\(['"](.+)['"]\)$/);
|
|
835
|
+
if (m) {
|
|
836
|
+
console.log(`[NodeTransform] ⬆️ :has-text → getByText("${m[1]}")`);
|
|
837
|
+
return { type: "getByText", text: m[1], exact: false };
|
|
838
|
+
}
|
|
839
|
+
return seg;
|
|
840
|
+
});
|
|
841
|
+
// ── Phase C: ChainValidator ─────────────────────────────────────
|
|
842
|
+
const validation = this.chainValidator.validate({
|
|
843
|
+
segments: workingSegments,
|
|
844
|
+
originalRootReceiver: forcedReceiver ?? "page",
|
|
845
|
+
proposedRootReceiver: forcedReceiver ?? "page",
|
|
846
|
+
rootType: "unknown",
|
|
847
|
+
applySemanticUpgrade: true,
|
|
848
|
+
});
|
|
849
|
+
if (!validation.valid) {
|
|
850
|
+
console.warn(`[NodeTransform] ❌ ChainValidator rejected: ${validation.reason}`);
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
const resolvedSegments = (validation.segments ??
|
|
854
|
+
workingSegments);
|
|
855
|
+
// ── Phase D: Find AST candidate nodes ──────────────────────────
|
|
856
|
+
const strictCandidates = this.findCandidateNodes(sourceFile, oldSelector, searchWindow);
|
|
857
|
+
const identityCandidates = suspectIdentifier
|
|
858
|
+
? this.findCandidatesInDeclaration(sourceFile, suspectIdentifier, oldSelector, caller.line)
|
|
859
|
+
: [];
|
|
860
|
+
const seenNodes = new WeakSet();
|
|
861
|
+
const candidates = [];
|
|
862
|
+
for (const c of strictCandidates) {
|
|
863
|
+
if (!seenNodes.has(c.call)) {
|
|
864
|
+
seenNodes.add(c.call);
|
|
865
|
+
candidates.push({ ...c, identityConfidence: false });
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
for (const c of identityCandidates) {
|
|
869
|
+
if (!seenNodes.has(c.call)) {
|
|
870
|
+
seenNodes.add(c.call);
|
|
871
|
+
candidates.push(c);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (candidates.length === 0) {
|
|
875
|
+
console.log(`[NodeTransform] ⚠️ No absolute matches found`);
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
candidates.sort((a, b) => {
|
|
879
|
+
if (a.identityConfidence !== b.identityConfidence) {
|
|
880
|
+
return a.identityConfidence ? 1 : -1;
|
|
881
|
+
}
|
|
882
|
+
return (Math.abs(a.call.getStartLineNumber() - caller.line) -
|
|
883
|
+
Math.abs(b.call.getStartLineNumber() - caller.line));
|
|
884
|
+
});
|
|
885
|
+
// ── Phase E: Build + Validate + Write ──────────────────────────
|
|
886
|
+
for (const { call, chainTop, rootReceiver: astRoot, chainDepth, identityConfidence, } of candidates) {
|
|
887
|
+
// ── Identifier/PropertyAccess guard ────────────────────────────
|
|
888
|
+
// NT must never rewrite a call whose first argument is an Identifier
|
|
889
|
+
// or PropertyAccessExpression (e.g. locator(SUBMIT_SELECTOR) or
|
|
890
|
+
// locator(SELS.submit)). Replacing the whole chain would substitute
|
|
891
|
+
// a hardcoded string literal for the reference, which is exactly the
|
|
892
|
+
// definition-site mutation bug the semantic dispatch gate was built
|
|
893
|
+
// to prevent. Skip the candidate; the semantic gate owns this case.
|
|
894
|
+
const firstArg = call.getArguments()[0];
|
|
895
|
+
if (firstArg &&
|
|
896
|
+
(ts_morph_1.Node.isIdentifier(firstArg) || ts_morph_1.Node.isPropertyAccessExpression(firstArg))) {
|
|
897
|
+
console.log(`[NodeTransform] 🛑 skip — first arg is ${firstArg.getKindName()} ` +
|
|
898
|
+
`"${firstArg.getText()}" (semantic dispatch owns definition-site mutations)`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const callLine = call.getStartLineNumber();
|
|
902
|
+
const originalText = chainTop.getText();
|
|
903
|
+
// ══════════════════════════════════════════════════════════════
|
|
904
|
+
// SCOPE-AWARE RECEIVER RESOLUTION ← THE CORE FIX
|
|
905
|
+
//
|
|
906
|
+
// Before we build code, we must verify that the proposed receiver
|
|
907
|
+
// does not create a circular self-reference.
|
|
908
|
+
//
|
|
909
|
+
// Case A — forcedReceiver is set (strip-and-force path):
|
|
910
|
+
// Check if the chainTop lives inside the initializer of that var.
|
|
911
|
+
// If yes, fall back to the actual AST root (e.g. "page").
|
|
912
|
+
//
|
|
913
|
+
// Case B — no forced receiver:
|
|
914
|
+
// Use astRoot directly — it was walked up from the call node and
|
|
915
|
+
// is always either "page" or a stable variable reference.
|
|
916
|
+
// ══════════════════════════════════════════════════════════════
|
|
917
|
+
let effectiveReceiver;
|
|
918
|
+
if (forcedReceiver) {
|
|
919
|
+
// resolveSafeReceiver checks isInsideOwnInitializer and falls back
|
|
920
|
+
// to resolvedRootReceiver ("page" etc.) when needed.
|
|
921
|
+
effectiveReceiver = resolveSafeReceiver(forcedReceiver, resolvedRootReceiver || astRoot, chainTop);
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
effectiveReceiver = astRoot;
|
|
925
|
+
}
|
|
926
|
+
// ── Additional safety: if effectiveReceiver still matches the variable
|
|
927
|
+
// name being declared (possible through deep var chains), force page.
|
|
928
|
+
if (suspectIdentifier &&
|
|
929
|
+
effectiveReceiver === suspectIdentifier &&
|
|
930
|
+
isInsideOwnInitializer(chainTop, suspectIdentifier)) {
|
|
931
|
+
console.log(`[ScopeGuard] 🔒 Secondary circular guard: ` +
|
|
932
|
+
`effectiveReceiver "${effectiveReceiver}" == suspectIdentifier ` +
|
|
933
|
+
`inside own initializer → forcing root "${astRoot}"`);
|
|
934
|
+
effectiveReceiver = astRoot;
|
|
935
|
+
}
|
|
936
|
+
console.log(`[NodeTransform] 📐 Receiver: forced=${forcedReceiver ?? "none"} ` +
|
|
937
|
+
`ast=${astRoot} → "${effectiveReceiver}" ` +
|
|
938
|
+
`(scopeSafe=${effectiveReceiver !== forcedReceiver ? "fallback" : "ok"})`);
|
|
939
|
+
const hintForPartial = originalChainHint &&
|
|
940
|
+
originalChainHint.length === resolvedSegments.length
|
|
941
|
+
? originalChainHint
|
|
942
|
+
: undefined;
|
|
943
|
+
if (!identityConfidence && hintForPartial) {
|
|
944
|
+
const partial = this.attemptPartialHeal(sourceFile, call, chainTop, effectiveReceiver, hintForPartial, resolvedSegments, callLine);
|
|
945
|
+
if (partial)
|
|
946
|
+
return partial;
|
|
947
|
+
}
|
|
948
|
+
const newCode = MethodMapper.buildChain(effectiveReceiver, resolvedSegments, originalText);
|
|
949
|
+
if (!newCode) {
|
|
950
|
+
console.warn(`[NodeTransform] ❌ MethodMapper failed for receiver "${effectiveReceiver}"`);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (!this.validateGeneratedCode(newCode, sourceFile)) {
|
|
954
|
+
console.warn(`[NodeTransform] ❌ Validation failed for:\n${newCode}`);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const tag = identityConfidence ? "🆔 identity" : "✅ strict full replace";
|
|
958
|
+
console.log(`[NodeTransform] ${tag} @line ${callLine} receiver="${effectiveReceiver}":\n` +
|
|
959
|
+
` ${newCode.split("\n")[0]}`);
|
|
960
|
+
try {
|
|
961
|
+
chainTop.replaceWithText(newCode);
|
|
962
|
+
sourceFile.saveSync();
|
|
963
|
+
return {
|
|
964
|
+
success: true,
|
|
965
|
+
reason: `scope-aware transform [${effectiveReceiver}]: ${resolvedSegments.length} segments`,
|
|
966
|
+
strategy: identityConfidence ? "NT-identity" : "NT",
|
|
967
|
+
lineUpdated: callLine - 1,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
catch (e) {
|
|
971
|
+
console.warn(`[NodeTransform] ⚠️ replaceWithText failed: ${e.message}`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
discoverAstRootReceiver(sourceFile, oldSelector, window, suspectIdentifier, callerLine) {
|
|
977
|
+
const allCandidates = [];
|
|
978
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
979
|
+
const line = call.getStartLineNumber();
|
|
980
|
+
if (line < window.start || line > window.end)
|
|
981
|
+
continue;
|
|
982
|
+
const args = call.getArguments();
|
|
983
|
+
if (args.length > 0 && ts_morph_1.Node.isTemplateExpression(args[0]))
|
|
984
|
+
continue;
|
|
985
|
+
if (!absoluteNodeMatch(call, oldSelector))
|
|
986
|
+
continue;
|
|
987
|
+
const { rootReceiver } = climbToChainTop(call);
|
|
988
|
+
if (rootReceiver && !PAGE_ROOT_NAMES.has(rootReceiver)) {
|
|
989
|
+
allCandidates.push({
|
|
990
|
+
receiver: rootReceiver,
|
|
991
|
+
distance: Math.abs(line - callerLine),
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (suspectIdentifier && !PAGE_ROOT_NAMES.has(suspectIdentifier)) {
|
|
996
|
+
const decl = jumpToDefinitionInFile(sourceFile, suspectIdentifier, callerLine + 5);
|
|
997
|
+
if (decl) {
|
|
998
|
+
const init = decl.getInitializer();
|
|
999
|
+
if (init && ts_morph_1.Node.isCallExpression(init)) {
|
|
1000
|
+
for (const call of decl.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
1001
|
+
const args = call.getArguments();
|
|
1002
|
+
if (args.length > 0 && ts_morph_1.Node.isTemplateExpression(args[0]))
|
|
1003
|
+
continue;
|
|
1004
|
+
if (!absoluteNodeMatch(call, oldSelector))
|
|
1005
|
+
continue;
|
|
1006
|
+
const { rootReceiver } = climbToChainTop(call);
|
|
1007
|
+
if (rootReceiver && !PAGE_ROOT_NAMES.has(rootReceiver)) {
|
|
1008
|
+
allCandidates.push({ receiver: rootReceiver, distance: 0 });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (allCandidates.length === 0)
|
|
1015
|
+
return null;
|
|
1016
|
+
allCandidates.sort((a, b) => a.distance - b.distance);
|
|
1017
|
+
const result = allCandidates[0].receiver;
|
|
1018
|
+
console.log(`[NodeTransform] 🔭 Discovered AST root receiver: "${result}"`);
|
|
1019
|
+
return result;
|
|
1020
|
+
}
|
|
1021
|
+
findCandidateNodes(sourceFile, oldSelector, window) {
|
|
1022
|
+
const results = [];
|
|
1023
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
1024
|
+
const line = call.getStartLineNumber();
|
|
1025
|
+
if (line < window.start || line > window.end)
|
|
1026
|
+
continue;
|
|
1027
|
+
const args = call.getArguments();
|
|
1028
|
+
if (args.length > 0 && ts_morph_1.Node.isTemplateExpression(args[0]))
|
|
1029
|
+
continue;
|
|
1030
|
+
if (!absoluteNodeMatch(call, oldSelector))
|
|
1031
|
+
continue;
|
|
1032
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(call);
|
|
1033
|
+
results.push({ call, chainTop, rootReceiver, chainDepth });
|
|
1034
|
+
}
|
|
1035
|
+
return results;
|
|
1036
|
+
}
|
|
1037
|
+
findCandidatesInDeclaration(sourceFile, varName, oldSelector, beforeLine) {
|
|
1038
|
+
const results = [];
|
|
1039
|
+
const decl = jumpToDefinitionInFile(sourceFile, varName, beforeLine + 5);
|
|
1040
|
+
if (!decl)
|
|
1041
|
+
return results;
|
|
1042
|
+
for (const call of decl.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
1043
|
+
const args = call.getArguments();
|
|
1044
|
+
if (args.length > 0 && ts_morph_1.Node.isTemplateExpression(args[0]))
|
|
1045
|
+
continue;
|
|
1046
|
+
if (!absoluteNodeMatch(call, oldSelector))
|
|
1047
|
+
continue;
|
|
1048
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(call);
|
|
1049
|
+
results.push({
|
|
1050
|
+
call,
|
|
1051
|
+
chainTop,
|
|
1052
|
+
rootReceiver,
|
|
1053
|
+
chainDepth,
|
|
1054
|
+
identityConfidence: false,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
const init = decl.getInitializer();
|
|
1058
|
+
if (init &&
|
|
1059
|
+
ts_morph_1.Node.isCallExpression(init) &&
|
|
1060
|
+
absoluteNodeMatch(init, oldSelector)) {
|
|
1061
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(init);
|
|
1062
|
+
results.push({
|
|
1063
|
+
call: init,
|
|
1064
|
+
chainTop,
|
|
1065
|
+
rootReceiver,
|
|
1066
|
+
chainDepth,
|
|
1067
|
+
identityConfidence: false,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
if (results.length === 0 && init && ts_morph_1.Node.isCallExpression(init)) {
|
|
1071
|
+
if (this.isPlaywrightLocatorCall(init)) {
|
|
1072
|
+
console.log(`[NodeTransform] 🆔 Identity override: '${varName}' @ line ${decl.getStartLineNumber()}`);
|
|
1073
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(init);
|
|
1074
|
+
results.push({
|
|
1075
|
+
call: init,
|
|
1076
|
+
chainTop,
|
|
1077
|
+
rootReceiver,
|
|
1078
|
+
chainDepth,
|
|
1079
|
+
identityConfidence: true,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
const pwCalls = decl
|
|
1084
|
+
.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)
|
|
1085
|
+
.filter((c) => {
|
|
1086
|
+
const args = c.getArguments();
|
|
1087
|
+
if (args.length > 0 && ts_morph_1.Node.isTemplateExpression(args[0]))
|
|
1088
|
+
return false;
|
|
1089
|
+
return this.isPlaywrightLocatorCall(c);
|
|
1090
|
+
});
|
|
1091
|
+
if (pwCalls.length > 0) {
|
|
1092
|
+
const innermost = pwCalls[pwCalls.length - 1];
|
|
1093
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(innermost);
|
|
1094
|
+
results.push({
|
|
1095
|
+
call: innermost,
|
|
1096
|
+
chainTop,
|
|
1097
|
+
rootReceiver,
|
|
1098
|
+
chainDepth,
|
|
1099
|
+
identityConfidence: true,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return results;
|
|
1105
|
+
}
|
|
1106
|
+
isPlaywrightLocatorCall(callExpr) {
|
|
1107
|
+
const expr = callExpr.getExpression();
|
|
1108
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
1109
|
+
return false;
|
|
1110
|
+
return ALL_SELECTOR_METHODS.has(expr.getName());
|
|
1111
|
+
}
|
|
1112
|
+
attemptPartialHeal(sourceFile, leafCall, chainTop, effectiveReceiver, originalSegments, newSegments, callLine) {
|
|
1113
|
+
if (JSON.stringify(originalSegments) === JSON.stringify(newSegments))
|
|
1114
|
+
return null;
|
|
1115
|
+
const changedIndices = [];
|
|
1116
|
+
for (let i = 0; i < originalSegments.length; i++) {
|
|
1117
|
+
if (JSON.stringify(originalSegments[i]) !== JSON.stringify(newSegments[i])) {
|
|
1118
|
+
changedIndices.push(i);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (changedIndices.length !== 1)
|
|
1122
|
+
return null;
|
|
1123
|
+
const changedIdx = changedIndices[0];
|
|
1124
|
+
const newSeg = newSegments[changedIdx];
|
|
1125
|
+
const chainCalls = collectChainCalls(chainTop);
|
|
1126
|
+
if (chainCalls.length <= changedIdx)
|
|
1127
|
+
return null;
|
|
1128
|
+
const targetCall = chainCalls[chainCalls.length - 1 - changedIdx];
|
|
1129
|
+
if (!targetCall)
|
|
1130
|
+
return null;
|
|
1131
|
+
const callReceiver = (() => {
|
|
1132
|
+
const expr = targetCall.getExpression();
|
|
1133
|
+
return ts_morph_1.Node.isPropertyAccessExpression(expr)
|
|
1134
|
+
? expr.getExpression().getText()
|
|
1135
|
+
: effectiveReceiver;
|
|
1136
|
+
})();
|
|
1137
|
+
const currentMethodName = (() => {
|
|
1138
|
+
const expr = targetCall.getExpression();
|
|
1139
|
+
return ts_morph_1.Node.isPropertyAccessExpression(expr) ? expr.getName() : "?";
|
|
1140
|
+
})();
|
|
1141
|
+
const newCallText = MethodMapper.build(callReceiver, newSeg);
|
|
1142
|
+
if (!newCallText)
|
|
1143
|
+
return null;
|
|
1144
|
+
if (!this.validateGeneratedCode(newCallText, sourceFile))
|
|
1145
|
+
return null;
|
|
1146
|
+
console.log(`[NodeTransform] 🔬 Partial: segment[${changedIdx}] .${currentMethodName}() → ${newCallText}`);
|
|
1147
|
+
try {
|
|
1148
|
+
targetCall.replaceWithText(newCallText);
|
|
1149
|
+
sourceFile.saveSync();
|
|
1150
|
+
return {
|
|
1151
|
+
success: true,
|
|
1152
|
+
reason: `partial: seg[${changedIdx}] .${currentMethodName}() → .${newSeg.type}()`,
|
|
1153
|
+
strategy: "NT-partial",
|
|
1154
|
+
lineUpdated: callLine - 1,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
catch (e) {
|
|
1158
|
+
console.warn(`[NodeTransform] ⚠️ Partial failed: ${e.message}`);
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
updateReceiverDefinition(sourceFile, varName, segType, newSelector, callerLine) {
|
|
1163
|
+
// Collect all matching declarations, then pick the closest one that
|
|
1164
|
+
// appears strictly BEFORE callerLine — prevents accidentally updating
|
|
1165
|
+
// a same-named variable in a different test block that happens to be
|
|
1166
|
+
// within the 100-line distance window.
|
|
1167
|
+
const declarations = sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration);
|
|
1168
|
+
let bestDecl = null;
|
|
1169
|
+
let bestDistance = Infinity;
|
|
1170
|
+
for (const decl of declarations) {
|
|
1171
|
+
if (decl.getName() !== varName)
|
|
1172
|
+
continue;
|
|
1173
|
+
const declLine = decl.getStartLineNumber();
|
|
1174
|
+
if (declLine >= callerLine)
|
|
1175
|
+
continue; // must be before caller
|
|
1176
|
+
const dist = callerLine - declLine;
|
|
1177
|
+
if (dist < bestDistance) {
|
|
1178
|
+
bestDistance = dist;
|
|
1179
|
+
bestDecl = decl;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (!bestDecl)
|
|
1183
|
+
return false;
|
|
1184
|
+
const initializer = bestDecl.getInitializer();
|
|
1185
|
+
if (!initializer)
|
|
1186
|
+
return false;
|
|
1187
|
+
const allCalls = [
|
|
1188
|
+
...(ts_morph_1.Node.isCallExpression(initializer) ? [initializer] : []),
|
|
1189
|
+
...initializer.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression),
|
|
1190
|
+
];
|
|
1191
|
+
for (const call of allCalls) {
|
|
1192
|
+
const expr = call.getExpression();
|
|
1193
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
1194
|
+
continue;
|
|
1195
|
+
if (expr.getName() !== segType)
|
|
1196
|
+
continue;
|
|
1197
|
+
const args = call.getArguments();
|
|
1198
|
+
if (args.length === 0)
|
|
1199
|
+
continue;
|
|
1200
|
+
const firstArg = args[0];
|
|
1201
|
+
if (!ts_morph_1.Node.isStringLiteral(firstArg))
|
|
1202
|
+
continue;
|
|
1203
|
+
firstArg.replaceWithText(`"${newSelector}"`);
|
|
1204
|
+
sourceFile.saveSync();
|
|
1205
|
+
return true;
|
|
1206
|
+
}
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
validateGeneratedCode(code, _sourceFile) {
|
|
1210
|
+
const receiverPattern = /\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\./g;
|
|
1211
|
+
const mockCandidates = new Set();
|
|
1212
|
+
const JS_KEYWORDS = new Set([
|
|
1213
|
+
"const",
|
|
1214
|
+
"let",
|
|
1215
|
+
"var",
|
|
1216
|
+
"function",
|
|
1217
|
+
"return",
|
|
1218
|
+
"await",
|
|
1219
|
+
"async",
|
|
1220
|
+
"new",
|
|
1221
|
+
"this",
|
|
1222
|
+
"typeof",
|
|
1223
|
+
"instanceof",
|
|
1224
|
+
"true",
|
|
1225
|
+
"false",
|
|
1226
|
+
"null",
|
|
1227
|
+
"undefined",
|
|
1228
|
+
"if",
|
|
1229
|
+
"else",
|
|
1230
|
+
"for",
|
|
1231
|
+
"while",
|
|
1232
|
+
"do",
|
|
1233
|
+
"switch",
|
|
1234
|
+
"case",
|
|
1235
|
+
"break",
|
|
1236
|
+
"continue",
|
|
1237
|
+
]);
|
|
1238
|
+
let m;
|
|
1239
|
+
while ((m = receiverPattern.exec(code)) !== null) {
|
|
1240
|
+
const name = m[1];
|
|
1241
|
+
if (!JS_KEYWORDS.has(name) && !ALL_SELECTOR_METHODS.has(name)) {
|
|
1242
|
+
mockCandidates.add(name);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
PAGE_ROOT_NAMES.forEach((n) => mockCandidates.add(n));
|
|
1246
|
+
const bareIdentifierMatch = code.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\./);
|
|
1247
|
+
if (bareIdentifierMatch) {
|
|
1248
|
+
mockCandidates.add(bareIdentifierMatch[1]);
|
|
1249
|
+
}
|
|
1250
|
+
const mockPreamble = [...mockCandidates]
|
|
1251
|
+
.map((id) => `const ${id}: any = {} as any;`)
|
|
1252
|
+
.join("\n");
|
|
1253
|
+
const wrappedCode = `${mockPreamble}\nconst __val = ${code.trim()};`;
|
|
1254
|
+
try {
|
|
1255
|
+
const testProject = new ts_morph_1.Project({
|
|
1256
|
+
useInMemoryFileSystem: true,
|
|
1257
|
+
compilerOptions: { noEmit: true, skipLibCheck: true, noResolve: true },
|
|
1258
|
+
});
|
|
1259
|
+
const testFile = testProject.createSourceFile(`tv_${Date.now()}.ts`, wrappedCode, { overwrite: true });
|
|
1260
|
+
const diags = testFile.getPreEmitDiagnostics();
|
|
1261
|
+
const ALLOWED_CODES = new Set([
|
|
1262
|
+
2304, 2339, 2345, 2554, 2571, 7006, 7016, 7031, 18004,
|
|
1263
|
+
]);
|
|
1264
|
+
const syntaxErrors = diags.filter((d) => {
|
|
1265
|
+
if (d.getCategory() !== 1)
|
|
1266
|
+
return false;
|
|
1267
|
+
return !ALLOWED_CODES.has(d.getCode());
|
|
1268
|
+
});
|
|
1269
|
+
if (syntaxErrors.length > 0) {
|
|
1270
|
+
console.warn(`[NodeTransform] ❌ Syntax errors: ` +
|
|
1271
|
+
syntaxErrors
|
|
1272
|
+
.map((d) => `[TS${d.getCode()}] ${d.getMessageText()}`)
|
|
1273
|
+
.join(", "));
|
|
1274
|
+
if (/\.(getBy\w+|frameLocator)\s*\(/.test(code)) {
|
|
1275
|
+
console.warn(`[NodeTransform] ⚠️ Semantic method detected — bypassing validation`);
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
if (testFile.getStatements().length === 0)
|
|
1281
|
+
return false;
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
catch (e) {
|
|
1285
|
+
console.warn(`[NodeTransform] ⚠️ Validation exception: ${e.message}, using heuristic`);
|
|
1286
|
+
return /\.(getBy\w+|locator|frameLocator)\s*\(/.test(code);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1291
|
+
// CHAIN ANALYSIS UTILITIES
|
|
1292
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1293
|
+
function climbToChainTop(startCall) {
|
|
1294
|
+
let chainTop = startCall;
|
|
1295
|
+
let chainDepth = 0;
|
|
1296
|
+
let cursor = startCall;
|
|
1297
|
+
while (true) {
|
|
1298
|
+
const parent = cursor.getParent();
|
|
1299
|
+
if (!parent)
|
|
1300
|
+
break;
|
|
1301
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(parent)) {
|
|
1302
|
+
cursor = parent;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (ts_morph_1.Node.isCallExpression(parent)) {
|
|
1306
|
+
const parentExpr = parent.getExpression();
|
|
1307
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(parentExpr)) {
|
|
1308
|
+
const methodName = parentExpr.getName();
|
|
1309
|
+
if (CHAIN_CONTINUATION_METHODS.has(methodName)) {
|
|
1310
|
+
chainTop = parent;
|
|
1311
|
+
chainDepth++;
|
|
1312
|
+
cursor = parent;
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
break;
|
|
1317
|
+
}
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
const findLeftmost = (n) => {
|
|
1321
|
+
if (ts_morph_1.Node.isCallExpression(n)) {
|
|
1322
|
+
const e = n.getExpression();
|
|
1323
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(e))
|
|
1324
|
+
return findLeftmost(e.getExpression());
|
|
1325
|
+
}
|
|
1326
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(n))
|
|
1327
|
+
return findLeftmost(n.getExpression());
|
|
1328
|
+
if (ts_morph_1.Node.isAwaitExpression(n))
|
|
1329
|
+
return findLeftmost(n.getExpression());
|
|
1330
|
+
return n
|
|
1331
|
+
.getText()
|
|
1332
|
+
.replace(/^await\s+/, "")
|
|
1333
|
+
.trim();
|
|
1334
|
+
};
|
|
1335
|
+
return { chainTop, rootReceiver: findLeftmost(chainTop), chainDepth };
|
|
1336
|
+
}
|
|
1337
|
+
function collectChainCalls(chainTop) {
|
|
1338
|
+
const calls = [];
|
|
1339
|
+
let current = chainTop;
|
|
1340
|
+
while (ts_morph_1.Node.isCallExpression(current)) {
|
|
1341
|
+
calls.push(current);
|
|
1342
|
+
const expr = current.getExpression();
|
|
1343
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(expr)) {
|
|
1344
|
+
current = expr.getExpression();
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return calls;
|
|
1351
|
+
}
|
|
1352
|
+
function isLeafOfChain(call) {
|
|
1353
|
+
const expr = call.getExpression();
|
|
1354
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
1355
|
+
return false;
|
|
1356
|
+
const receiver = expr.getExpression();
|
|
1357
|
+
if (ts_morph_1.Node.isCallExpression(receiver))
|
|
1358
|
+
return true;
|
|
1359
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(receiver)) {
|
|
1360
|
+
return ts_morph_1.Node.isCallExpression(receiver.getExpression());
|
|
1361
|
+
}
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
function analyzeVariableInitializerChain(decl) {
|
|
1365
|
+
const initializer = decl.getInitializer();
|
|
1366
|
+
if (!initializer)
|
|
1367
|
+
return null;
|
|
1368
|
+
if (!ts_morph_1.Node.isCallExpression(initializer))
|
|
1369
|
+
return null;
|
|
1370
|
+
const countChainDepth = (n, depth) => {
|
|
1371
|
+
if (ts_morph_1.Node.isCallExpression(n)) {
|
|
1372
|
+
const e = n.getExpression();
|
|
1373
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(e)) {
|
|
1374
|
+
const methodName = e.getName();
|
|
1375
|
+
if (CHAIN_CONTINUATION_METHODS.has(methodName)) {
|
|
1376
|
+
return countChainDepth(e.getExpression(), depth + 1);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(n))
|
|
1381
|
+
return countChainDepth(n.getExpression(), depth);
|
|
1382
|
+
return {
|
|
1383
|
+
depth,
|
|
1384
|
+
root: n
|
|
1385
|
+
.getText()
|
|
1386
|
+
.replace(/^await\s+/, "")
|
|
1387
|
+
.trim(),
|
|
1388
|
+
};
|
|
1389
|
+
};
|
|
1390
|
+
const { depth, root } = countChainDepth(initializer, 0);
|
|
1391
|
+
if (depth === 0)
|
|
1392
|
+
return null;
|
|
1393
|
+
return { fullChainNode: initializer, rootReceiver: root, chainDepth: depth };
|
|
1394
|
+
}
|
|
1395
|
+
function jumpToDefinitionInFile(sourceFile, varName, beforeLine) {
|
|
1396
|
+
const text = sourceFile.getFullText();
|
|
1397
|
+
const lines = text.split("\n");
|
|
1398
|
+
let beforeOffset = 0;
|
|
1399
|
+
for (let i = 0; i < Math.min(beforeLine - 1, lines.length); i++) {
|
|
1400
|
+
beforeOffset += lines[i].length + 1;
|
|
1401
|
+
}
|
|
1402
|
+
let best = null;
|
|
1403
|
+
let bestOffset = -1;
|
|
1404
|
+
for (const decl of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)) {
|
|
1405
|
+
if (decl.getName() !== varName)
|
|
1406
|
+
continue;
|
|
1407
|
+
const offset = decl.getStart();
|
|
1408
|
+
if (offset < beforeOffset && offset > bestOffset) {
|
|
1409
|
+
best = decl;
|
|
1410
|
+
bestOffset = offset;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (best) {
|
|
1414
|
+
console.log(`[ASTUpdater] 🎯 JumpToDef: '${varName}' @ line ${best.getStartLineNumber()}`);
|
|
1415
|
+
}
|
|
1416
|
+
return best;
|
|
1417
|
+
}
|
|
1418
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1419
|
+
// ChainValidator integration
|
|
1420
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1421
|
+
const chainValidator = new ChainValidator_1.ChainValidator();
|
|
1422
|
+
const nodeTransformer = new NodeTransformationStrategy(chainValidator);
|
|
1423
|
+
function buildSmartChainCode(receiver, smartSegments, originalReceiver, rootType = "unknown", originalText) {
|
|
1424
|
+
const segments = smartSegments;
|
|
1425
|
+
const result = chainValidator.validate({
|
|
1426
|
+
segments,
|
|
1427
|
+
originalRootReceiver: originalReceiver,
|
|
1428
|
+
proposedRootReceiver: receiver,
|
|
1429
|
+
rootType,
|
|
1430
|
+
applySemanticUpgrade: true,
|
|
1431
|
+
});
|
|
1432
|
+
if (!result.valid) {
|
|
1433
|
+
console.warn(`[ASTUpdater] ❌ ChainValidator rejected segments: ${result.reason}`);
|
|
1434
|
+
return null;
|
|
1435
|
+
}
|
|
1436
|
+
const finalSegments = (result.segments ??
|
|
1437
|
+
segments);
|
|
1438
|
+
const code = MethodMapper.buildChain(receiver, finalSegments, originalText);
|
|
1439
|
+
if (code)
|
|
1440
|
+
return code;
|
|
1441
|
+
const multiLine = finalSegments.length >= 3;
|
|
1442
|
+
return (0, ChainValidator_1.segmentsToCode)(receiver, result.segments ?? segments, multiLine);
|
|
1443
|
+
}
|
|
1444
|
+
const TEXT_MATCHERS = new Set([
|
|
1445
|
+
"toHaveText",
|
|
1446
|
+
"toContainText",
|
|
1447
|
+
"toHaveValue",
|
|
1448
|
+
"toHaveAttribute",
|
|
1449
|
+
"toHaveTitle",
|
|
1450
|
+
"toHaveLabel",
|
|
1451
|
+
]);
|
|
1452
|
+
function findAssertionsInWindow(sourceFile, locatorVarName, callerLine, windowLines) {
|
|
1453
|
+
const results = [];
|
|
1454
|
+
const scanStart = Math.max(1, callerLine - 2);
|
|
1455
|
+
const scanEnd = Math.min(sourceFile.getEndLineNumber(), callerLine + windowLines);
|
|
1456
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
1457
|
+
const line = call.getStartLineNumber();
|
|
1458
|
+
if (line < scanStart || line > scanEnd)
|
|
1459
|
+
continue;
|
|
1460
|
+
const expr = call.getExpression();
|
|
1461
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
1462
|
+
continue;
|
|
1463
|
+
if (!TEXT_MATCHERS.has(expr.getName()))
|
|
1464
|
+
continue;
|
|
1465
|
+
let expectCallNode = null;
|
|
1466
|
+
let cur = expr.getExpression();
|
|
1467
|
+
for (let i = 0; i < 8; i++) {
|
|
1468
|
+
if (ts_morph_1.Node.isCallExpression(cur)) {
|
|
1469
|
+
const inner = cur.getExpression();
|
|
1470
|
+
if (ts_morph_1.Node.isIdentifier(inner) && inner.getText() === "expect") {
|
|
1471
|
+
expectCallNode = cur;
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(inner)) {
|
|
1475
|
+
const nm = inner.getName();
|
|
1476
|
+
if (nm === "not" || nm === "soft" || nm === "poll") {
|
|
1477
|
+
cur = inner.getExpression();
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(cur)) {
|
|
1483
|
+
cur = cur.getExpression();
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1488
|
+
if (!expectCallNode)
|
|
1489
|
+
continue;
|
|
1490
|
+
const expectArgs = expectCallNode.getArguments();
|
|
1491
|
+
if (expectArgs.length === 0)
|
|
1492
|
+
continue;
|
|
1493
|
+
const argText = expectArgs[0].getText();
|
|
1494
|
+
if (locatorVarName !== null) {
|
|
1495
|
+
const matches = argText === locatorVarName ||
|
|
1496
|
+
argText.startsWith(locatorVarName + ".") ||
|
|
1497
|
+
argText.startsWith(locatorVarName + " ");
|
|
1498
|
+
if (!matches)
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
const matcherArgs = call.getArguments();
|
|
1502
|
+
if (matcherArgs.length === 0)
|
|
1503
|
+
continue;
|
|
1504
|
+
const firstMatcherArg = matcherArgs[0];
|
|
1505
|
+
if (ts_morph_1.Node.isRegularExpressionLiteral(firstMatcherArg))
|
|
1506
|
+
continue;
|
|
1507
|
+
let currentValue = null;
|
|
1508
|
+
if (ts_morph_1.Node.isStringLiteral(firstMatcherArg)) {
|
|
1509
|
+
currentValue = firstMatcherArg.getLiteralValue();
|
|
1510
|
+
}
|
|
1511
|
+
if (currentValue === null)
|
|
1512
|
+
continue;
|
|
1513
|
+
results.push({
|
|
1514
|
+
expectCall: expectCallNode,
|
|
1515
|
+
matcherCall: call,
|
|
1516
|
+
matcherName: expr.getName(),
|
|
1517
|
+
currentValue,
|
|
1518
|
+
line,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
return results;
|
|
1522
|
+
}
|
|
1523
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1524
|
+
// MAIN CLASS
|
|
1525
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1526
|
+
class ASTSourceUpdater {
|
|
1527
|
+
project;
|
|
1528
|
+
advisoryBuffer = new HealingAdvisory_1.AdvisoryBuffer();
|
|
1529
|
+
flushAdvisories() {
|
|
1530
|
+
this.advisoryBuffer.flush();
|
|
1531
|
+
}
|
|
1532
|
+
constructor() {
|
|
1533
|
+
this.project = new ts_morph_1.Project({
|
|
1534
|
+
useInMemoryFileSystem: false,
|
|
1535
|
+
skipAddingFilesFromTsConfig: true,
|
|
1536
|
+
compilerOptions: {
|
|
1537
|
+
allowJs: true,
|
|
1538
|
+
noEmit: true,
|
|
1539
|
+
skipLibCheck: true,
|
|
1540
|
+
noResolve: true,
|
|
1541
|
+
},
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
WRAPPER_IDENTIFIERS = new Set([
|
|
1545
|
+
"test",
|
|
1546
|
+
"it",
|
|
1547
|
+
"describe",
|
|
1548
|
+
"beforeEach",
|
|
1549
|
+
"afterEach",
|
|
1550
|
+
"beforeAll",
|
|
1551
|
+
"afterAll",
|
|
1552
|
+
"expect",
|
|
1553
|
+
"page",
|
|
1554
|
+
"await",
|
|
1555
|
+
"step",
|
|
1556
|
+
"context",
|
|
1557
|
+
"suite",
|
|
1558
|
+
]);
|
|
1559
|
+
STRUCTURAL_ROLES = new Set([
|
|
1560
|
+
"heading",
|
|
1561
|
+
"banner",
|
|
1562
|
+
"main",
|
|
1563
|
+
"navigation",
|
|
1564
|
+
"nav",
|
|
1565
|
+
"complementary",
|
|
1566
|
+
"aside",
|
|
1567
|
+
"contentinfo",
|
|
1568
|
+
"footer",
|
|
1569
|
+
"region",
|
|
1570
|
+
"landmark",
|
|
1571
|
+
"article",
|
|
1572
|
+
"section",
|
|
1573
|
+
"list",
|
|
1574
|
+
"listitem",
|
|
1575
|
+
"term",
|
|
1576
|
+
"definition",
|
|
1577
|
+
"figure",
|
|
1578
|
+
"img",
|
|
1579
|
+
"presentation",
|
|
1580
|
+
"none",
|
|
1581
|
+
"separator",
|
|
1582
|
+
"scrollbar",
|
|
1583
|
+
"log",
|
|
1584
|
+
"marquee",
|
|
1585
|
+
"status",
|
|
1586
|
+
"timer",
|
|
1587
|
+
"alert",
|
|
1588
|
+
"alertdialog",
|
|
1589
|
+
]);
|
|
1590
|
+
INTERACTIVE_ROLES = new Set([
|
|
1591
|
+
"textbox",
|
|
1592
|
+
"searchbox",
|
|
1593
|
+
"spinbutton",
|
|
1594
|
+
"button",
|
|
1595
|
+
"checkbox",
|
|
1596
|
+
"radio",
|
|
1597
|
+
"switch",
|
|
1598
|
+
"combobox",
|
|
1599
|
+
"listbox",
|
|
1600
|
+
"option",
|
|
1601
|
+
"menuitem",
|
|
1602
|
+
"menuitemcheckbox",
|
|
1603
|
+
"menuitemradio",
|
|
1604
|
+
"link",
|
|
1605
|
+
"tab",
|
|
1606
|
+
"slider",
|
|
1607
|
+
"progressbar",
|
|
1608
|
+
"grid",
|
|
1609
|
+
"gridcell",
|
|
1610
|
+
"row",
|
|
1611
|
+
"rowheader",
|
|
1612
|
+
"columnheader",
|
|
1613
|
+
]);
|
|
1614
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1615
|
+
// PUBLIC ENTRY POINT
|
|
1616
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1617
|
+
update(caller, oldSelector, newSelector, aiSegments, fullSelectorContext, contentChange, smartChainSegments, originalChainHint) {
|
|
1618
|
+
const filePath = this.resolveFilePath(caller.filePath);
|
|
1619
|
+
if (!filePath) {
|
|
1620
|
+
return {
|
|
1621
|
+
success: false,
|
|
1622
|
+
reason: `File not found: ${caller.filePath}`,
|
|
1623
|
+
strategy: "none",
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
console.log(`\n[ASTUpdater] ═══════════════════════════════`);
|
|
1627
|
+
console.log(`[ASTUpdater] 📂 ${path.basename(filePath)} @ line ${caller.line}`);
|
|
1628
|
+
console.log(`[ASTUpdater] 🔄 old: "${oldSelector}"`);
|
|
1629
|
+
console.log(`[ASTUpdater] → new: "${newSelector}"`);
|
|
1630
|
+
if (smartChainSegments) {
|
|
1631
|
+
console.log(`[ASTUpdater] 🔗 smart chain segments: ${JSON.stringify(smartChainSegments)}`);
|
|
1632
|
+
}
|
|
1633
|
+
if (contentChange) {
|
|
1634
|
+
console.log(`[ASTUpdater] 📝 content change: "${contentChange.oldText}" → "${contentChange.newText}"`);
|
|
1635
|
+
}
|
|
1636
|
+
console.log(`[ASTUpdater] ═══════════════════════════════`);
|
|
1637
|
+
const oldSem = parseInternalSelector(oldSelector
|
|
1638
|
+
.split(">>")
|
|
1639
|
+
.pop()
|
|
1640
|
+
.trim()
|
|
1641
|
+
.replace(/\s*i\s*$/, ""));
|
|
1642
|
+
const newSem = parseInternalSelector(newSelector
|
|
1643
|
+
.split(">>")
|
|
1644
|
+
.pop()
|
|
1645
|
+
.trim()
|
|
1646
|
+
.replace(/\s*i\s*$/, ""));
|
|
1647
|
+
console.log(`[ASTUpdater] 🔬 old sem: ${oldSem.method}("${oldSem.primaryArg}")`);
|
|
1648
|
+
console.log(`[ASTUpdater] 🔬 new sem: ${newSem.method}("${newSem.primaryArg}")`);
|
|
1649
|
+
let sourceFile = this.project.getSourceFile(filePath);
|
|
1650
|
+
if (sourceFile) {
|
|
1651
|
+
sourceFile.refreshFromFileSystemSync();
|
|
1652
|
+
}
|
|
1653
|
+
else {
|
|
1654
|
+
sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
1655
|
+
}
|
|
1656
|
+
if (caller.line <= 0) {
|
|
1657
|
+
console.log(`[ASTUpdater] ⚠️ line ${caller.line} invalid — Attempting Global Template Strategy`);
|
|
1658
|
+
const globalResult = this.strategyTemplateLiteral(sourceFile, caller, oldSelector, newSelector);
|
|
1659
|
+
if (globalResult && globalResult.success) {
|
|
1660
|
+
if (contentChange)
|
|
1661
|
+
this.runStrategyG(sourceFile, null, null, contentChange);
|
|
1662
|
+
return this.broadcastAndReturn(globalResult, oldSelector, newSelector, caller);
|
|
1663
|
+
}
|
|
1664
|
+
if (contentChange)
|
|
1665
|
+
this.runStrategyG(sourceFile, null, null, contentChange);
|
|
1666
|
+
return {
|
|
1667
|
+
success: false,
|
|
1668
|
+
reason: "invalid line number (≤0) and global fallback failed",
|
|
1669
|
+
strategy: "none",
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
const failureNode = this.findNodeAtLine(sourceFile, caller.line);
|
|
1673
|
+
const statement = failureNode
|
|
1674
|
+
? this.getEnclosingStatement(failureNode)
|
|
1675
|
+
: undefined;
|
|
1676
|
+
const suspectIdentifier = statement
|
|
1677
|
+
? this.identifySuspectIdentifier(statement, caller.line)
|
|
1678
|
+
: null;
|
|
1679
|
+
if (suspectIdentifier) {
|
|
1680
|
+
console.log(`[ASTUpdater] 🎯 Primary suspect: '${suspectIdentifier}'`);
|
|
1681
|
+
}
|
|
1682
|
+
// ─── T-10: Semantic Dispatch Gate ──────────────────────────────
|
|
1683
|
+
// HARD EXIT: any non-null result (success OR failure) stops all
|
|
1684
|
+
// subsequent AST strategies. When the locator argument is an
|
|
1685
|
+
// Identifier, PropertyAccess, or TemplateLiteral, the gate owns
|
|
1686
|
+
// the mutation — NT/H must never override the symbolic reference.
|
|
1687
|
+
const semanticResult = this.semanticDispatch(sourceFile, caller, oldSelector, newSelector);
|
|
1688
|
+
if (semanticResult !== null) {
|
|
1689
|
+
if (semanticResult.success) {
|
|
1690
|
+
if (contentChange) {
|
|
1691
|
+
this.runStrategyG(sourceFile, caller, suspectIdentifier, contentChange);
|
|
1692
|
+
}
|
|
1693
|
+
return this.broadcastAndReturn(semanticResult, oldSelector, newSelector, caller);
|
|
1694
|
+
}
|
|
1695
|
+
// Failure (blast-radius block, trace failed, syntax error) — hard stop.
|
|
1696
|
+
console.log(`[SemanticDispatch] 🚫 Hard stop (${semanticResult.strategy}): ${semanticResult.reason}`);
|
|
1697
|
+
return semanticResult;
|
|
1698
|
+
}
|
|
1699
|
+
// ───────────────────────────────────────────────────────────────
|
|
1700
|
+
const startLine = Math.max(1, caller.line - 40);
|
|
1701
|
+
const endLine = Math.min(sourceFile.getEndLineNumber(), caller.line + 5);
|
|
1702
|
+
const structuralStrategies = [
|
|
1703
|
+
() => smartChainSegments && smartChainSegments.length > 0
|
|
1704
|
+
? nodeTransformer.replaceLocatorNode(sourceFile, caller, oldSelector, smartChainSegments, originalChainHint, suspectIdentifier, { start: startLine, end: endLine })
|
|
1705
|
+
: null,
|
|
1706
|
+
() => smartChainSegments && smartChainSegments.length > 0
|
|
1707
|
+
? this.strategySmartChain(sourceFile, caller, oldSelector, oldSem, smartChainSegments, originalChainHint, suspectIdentifier)
|
|
1708
|
+
: null,
|
|
1709
|
+
() => aiSegments && aiSegments.length > 0 && fullSelectorContext
|
|
1710
|
+
? this.strategyFrameUpstream(sourceFile, caller, oldSelector, aiSegments, fullSelectorContext)
|
|
1711
|
+
: null,
|
|
1712
|
+
() => this.strategyChainCollapse(sourceFile, caller, oldSelector, newSelector, oldSem, newSem),
|
|
1713
|
+
() => statement
|
|
1714
|
+
? this.strategyDeepSymbolResolution(sourceFile, caller, oldSelector, newSelector, oldSem, newSem, suspectIdentifier)
|
|
1715
|
+
: null,
|
|
1716
|
+
() => this.strategyInlineSemanticFix(sourceFile, caller, oldSelector, newSelector, oldSem, newSem),
|
|
1717
|
+
() => this.strategyTemplateLiteral(sourceFile, caller, oldSelector, newSelector),
|
|
1718
|
+
() => this.strategySemanticFragmentScan(sourceFile, caller, oldSelector, newSelector, oldSem, newSem),
|
|
1719
|
+
];
|
|
1720
|
+
for (const strategyFn of structuralStrategies) {
|
|
1721
|
+
const result = strategyFn();
|
|
1722
|
+
if (result && result.success) {
|
|
1723
|
+
if (contentChange) {
|
|
1724
|
+
this.runStrategyG(sourceFile, caller, suspectIdentifier, contentChange);
|
|
1725
|
+
}
|
|
1726
|
+
return this.broadcastAndReturn(result, oldSelector, newSelector, caller);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
if (contentChange) {
|
|
1730
|
+
this.runStrategyG(sourceFile, caller, suspectIdentifier, contentChange);
|
|
1731
|
+
return {
|
|
1732
|
+
success: false,
|
|
1733
|
+
reason: "Assertion drift detected - manual review required",
|
|
1734
|
+
strategy: "G",
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
return {
|
|
1738
|
+
success: false,
|
|
1739
|
+
reason: "All AST strategies exhausted",
|
|
1740
|
+
strategy: "none",
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1744
|
+
// STRATEGY H — Smart Chain (Legacy fallback)
|
|
1745
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1746
|
+
strategySmartChain(sourceFile, caller, oldSelector, oldSem, smartSegments, originalChainHint, suspectIdentifier) {
|
|
1747
|
+
console.log(`[ASTUpdater] 🔗 Strategy H v2: Smart Chain (${smartSegments.length} segments)`);
|
|
1748
|
+
const validationResult = chainValidator.validate({
|
|
1749
|
+
segments: smartSegments,
|
|
1750
|
+
originalRootReceiver: "page",
|
|
1751
|
+
proposedRootReceiver: "page",
|
|
1752
|
+
rootType: "unknown",
|
|
1753
|
+
applySemanticUpgrade: true,
|
|
1754
|
+
});
|
|
1755
|
+
if (!validationResult.valid) {
|
|
1756
|
+
console.warn(`[ASTUpdater] 🔗 H: ChainValidator pre-check failed: ${validationResult.reason}`);
|
|
1757
|
+
return null;
|
|
1758
|
+
}
|
|
1759
|
+
const resolvedSegments = (validationResult.segments ??
|
|
1760
|
+
smartSegments);
|
|
1761
|
+
const startLine = Math.max(1, caller.line - 40);
|
|
1762
|
+
const endLine = Math.min(sourceFile.getEndLineNumber(), caller.line + 5);
|
|
1763
|
+
const candidates = [];
|
|
1764
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
1765
|
+
const callLine = call.getStartLineNumber();
|
|
1766
|
+
if (callLine < startLine || callLine > endLine)
|
|
1767
|
+
continue;
|
|
1768
|
+
const expr = call.getExpression();
|
|
1769
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
1770
|
+
continue;
|
|
1771
|
+
const args = call.getArguments();
|
|
1772
|
+
if (args.length > 0 && ts_morph_1.Node.isTemplateExpression(args[0]))
|
|
1773
|
+
continue;
|
|
1774
|
+
const callSem = extractSemanticFromCall(call);
|
|
1775
|
+
if (!callSem)
|
|
1776
|
+
continue;
|
|
1777
|
+
if (!semanticMatch(callSem, oldSelector, true))
|
|
1778
|
+
continue;
|
|
1779
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(call);
|
|
1780
|
+
const isChained = chainDepth > 0 || isLeafOfChain(call);
|
|
1781
|
+
const effectiveDepth = chainDepth > 0 ? chainDepth : isChained ? 1 : 0;
|
|
1782
|
+
candidates.push({
|
|
1783
|
+
leafCall: call,
|
|
1784
|
+
chainTop,
|
|
1785
|
+
rootReceiver,
|
|
1786
|
+
chainDepth: effectiveDepth,
|
|
1787
|
+
distance: Math.abs(callLine - caller.line),
|
|
1788
|
+
originalText: chainTop.getText(),
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
if (suspectIdentifier && candidates.length === 0) {
|
|
1792
|
+
const decl = jumpToDefinitionInFile(sourceFile, suspectIdentifier, caller.line);
|
|
1793
|
+
if (decl) {
|
|
1794
|
+
const initChain = analyzeVariableInitializerChain(decl);
|
|
1795
|
+
if (initChain) {
|
|
1796
|
+
const { chainTop, rootReceiver } = climbToChainTop(initChain.fullChainNode);
|
|
1797
|
+
candidates.push({
|
|
1798
|
+
leafCall: initChain.fullChainNode,
|
|
1799
|
+
chainTop,
|
|
1800
|
+
rootReceiver,
|
|
1801
|
+
chainDepth: initChain.chainDepth,
|
|
1802
|
+
distance: Math.abs(decl.getStartLineNumber() - caller.line),
|
|
1803
|
+
originalText: chainTop.getText(),
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
if (candidates.length === 0) {
|
|
1809
|
+
console.log(`[ASTUpdater] 🔗 H: no matching candidates found`);
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
candidates.sort((a, b) => {
|
|
1813
|
+
if (a.distance !== b.distance)
|
|
1814
|
+
return a.distance - b.distance;
|
|
1815
|
+
return b.chainDepth - a.chainDepth;
|
|
1816
|
+
});
|
|
1817
|
+
for (const { leafCall, chainTop, rootReceiver, chainDepth, originalText, } of candidates) {
|
|
1818
|
+
// Identifier/PropertyAccess guard — mirrors the NT guard.
|
|
1819
|
+
// Strategy H must never substitute a hardcoded string for a symbolic
|
|
1820
|
+
// reference; the semantic dispatch gate owns definition-site mutations.
|
|
1821
|
+
const firstArgH = leafCall.getArguments()[0];
|
|
1822
|
+
if (firstArgH &&
|
|
1823
|
+
(ts_morph_1.Node.isIdentifier(firstArgH) || ts_morph_1.Node.isPropertyAccessExpression(firstArgH))) {
|
|
1824
|
+
console.log(`[StrategyH] 🛑 skip — first arg is ${firstArgH.getKindName()} ` +
|
|
1825
|
+
`"${firstArgH.getText()}" (semantic dispatch owns definition-site mutations)`);
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
const callLine = leafCall.getStartLineNumber();
|
|
1829
|
+
if (originalChainHint &&
|
|
1830
|
+
originalChainHint.length === resolvedSegments.length) {
|
|
1831
|
+
const partialResult = this.attemptPartialChainHeal(sourceFile, caller, chainTop, rootReceiver, originalChainHint, resolvedSegments, callLine);
|
|
1832
|
+
if (partialResult && partialResult.success)
|
|
1833
|
+
return partialResult;
|
|
1834
|
+
}
|
|
1835
|
+
// ── Scope safety for Strategy H ─────────────────────────────────
|
|
1836
|
+
// If this candidate's chainTop lives inside the own initializer of
|
|
1837
|
+
// suspectIdentifier, we must use rootReceiver (page), not the var.
|
|
1838
|
+
const safeRootReceiver = suspectIdentifier && isInsideOwnInitializer(chainTop, suspectIdentifier)
|
|
1839
|
+
? (() => {
|
|
1840
|
+
console.log(`[ScopeGuard-H] 🔒 Circular guard: rootReceiver adjusted from ` +
|
|
1841
|
+
`"${rootReceiver}" (inside '${suspectIdentifier}' init) → using AST root`);
|
|
1842
|
+
return rootReceiver; // rootReceiver from climbToChainTop is always page/root
|
|
1843
|
+
})()
|
|
1844
|
+
: rootReceiver;
|
|
1845
|
+
const newCode = buildSmartChainCode(safeRootReceiver, resolvedSegments, safeRootReceiver, "unknown", originalText);
|
|
1846
|
+
if (!newCode) {
|
|
1847
|
+
console.warn(`[ASTUpdater] 🔗 H: MethodMapper/ChainValidator produced no code`);
|
|
1848
|
+
continue;
|
|
1849
|
+
}
|
|
1850
|
+
console.log(`[ASTUpdater] 🔗 H: full replace (depth=${chainDepth}) →\n${newCode}`);
|
|
1851
|
+
try {
|
|
1852
|
+
chainTop.replaceWithText(newCode);
|
|
1853
|
+
sourceFile.saveSync();
|
|
1854
|
+
return {
|
|
1855
|
+
success: true,
|
|
1856
|
+
reason: `smart chain applied (${resolvedSegments.length} segments, depth=${chainDepth})`,
|
|
1857
|
+
strategy: "H",
|
|
1858
|
+
lineUpdated: callLine - 1,
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
catch (e) {
|
|
1862
|
+
console.warn(`[ASTUpdater] ⚠️ H: replaceWithText failed: ${e.message}`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
console.log(`[ASTUpdater] 🔗 H: all candidates exhausted`);
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
attemptPartialChainHeal(sourceFile, caller, chainTop, rootReceiver, originalSegments, newSegments, callLine) {
|
|
1869
|
+
if (JSON.stringify(originalSegments) === JSON.stringify(newSegments))
|
|
1870
|
+
return null;
|
|
1871
|
+
const changedIndices = [];
|
|
1872
|
+
for (let i = 0; i < originalSegments.length; i++) {
|
|
1873
|
+
if (JSON.stringify(originalSegments[i]) !== JSON.stringify(newSegments[i])) {
|
|
1874
|
+
changedIndices.push(i);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
if (changedIndices.length === 0 || changedIndices.length > 1)
|
|
1878
|
+
return null;
|
|
1879
|
+
const changedIdx = changedIndices[0];
|
|
1880
|
+
const oldSeg = originalSegments[changedIdx];
|
|
1881
|
+
const newSeg = newSegments[changedIdx];
|
|
1882
|
+
console.log(`[ASTUpdater] 🔗 H partial: segment[${changedIdx}] changed: ` +
|
|
1883
|
+
`${JSON.stringify(oldSeg)} → ${JSON.stringify(newSeg)}`);
|
|
1884
|
+
const chainCalls = collectChainCalls(chainTop);
|
|
1885
|
+
if (chainCalls.length <= changedIdx)
|
|
1886
|
+
return null;
|
|
1887
|
+
const targetCallIdx = chainCalls.length - 1 - changedIdx;
|
|
1888
|
+
const targetCall = chainCalls[targetCallIdx];
|
|
1889
|
+
if (!targetCall)
|
|
1890
|
+
return null;
|
|
1891
|
+
// Block H-partial from substituting a literal for an Identifier/PropertyAccess arg.
|
|
1892
|
+
const targetFirstArg = targetCall.getArguments()[0];
|
|
1893
|
+
if (targetFirstArg &&
|
|
1894
|
+
(ts_morph_1.Node.isIdentifier(targetFirstArg) || ts_morph_1.Node.isPropertyAccessExpression(targetFirstArg))) {
|
|
1895
|
+
console.log(`[H-partial] 🛑 skip — arg is ${targetFirstArg.getKindName()} ` +
|
|
1896
|
+
`"${targetFirstArg.getText()}" (semantic dispatch owns this)`);
|
|
1897
|
+
return null;
|
|
1898
|
+
}
|
|
1899
|
+
const callReceiver = (() => {
|
|
1900
|
+
const expr = targetCall.getExpression();
|
|
1901
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
1902
|
+
return expr.getExpression().getText();
|
|
1903
|
+
return rootReceiver;
|
|
1904
|
+
})();
|
|
1905
|
+
const currentMethodName = (() => {
|
|
1906
|
+
const expr = targetCall.getExpression();
|
|
1907
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
1908
|
+
return expr.getName();
|
|
1909
|
+
return "?";
|
|
1910
|
+
})();
|
|
1911
|
+
const newCallText = MethodMapper.build(callReceiver, newSeg);
|
|
1912
|
+
if (newCallText === null)
|
|
1913
|
+
return null;
|
|
1914
|
+
console.log(`[ASTUpdater] 🔗 H partial: .${currentMethodName}() → ${newCallText}`);
|
|
1915
|
+
try {
|
|
1916
|
+
targetCall.replaceWithText(newCallText);
|
|
1917
|
+
sourceFile.saveSync();
|
|
1918
|
+
return {
|
|
1919
|
+
success: true,
|
|
1920
|
+
reason: `partial chain heal: segment[${changedIdx}] .${currentMethodName}() → .${newSeg.type}()`,
|
|
1921
|
+
strategy: "H-partial",
|
|
1922
|
+
lineUpdated: callLine - 1,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
catch (e) {
|
|
1926
|
+
console.warn(`[ASTUpdater] ⚠️ H partial: replaceWithText failed: ${e.message}`);
|
|
1927
|
+
return null;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
// ─────────────────────────────────────────────────────────────
|
|
1931
|
+
// STRATEGY F — Chain Collapse
|
|
1932
|
+
// ─────────────────────────────────────────────────────────────
|
|
1933
|
+
strategyChainCollapse(sourceFile, caller, oldSelector, newSelector, oldSem, newSem) {
|
|
1934
|
+
console.log(`[ASTUpdater] ⛓️ Strategy F: Chain Collapse (upward traversal)`);
|
|
1935
|
+
const startLine = Math.max(1, caller.line - 40);
|
|
1936
|
+
const endLine = Math.min(sourceFile.getEndLineNumber(), caller.line + 5);
|
|
1937
|
+
const candidates = [];
|
|
1938
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
1939
|
+
const callLine = call.getStartLineNumber();
|
|
1940
|
+
if (callLine < startLine || callLine > endLine)
|
|
1941
|
+
continue;
|
|
1942
|
+
const callSem = extractSemanticFromCall(call);
|
|
1943
|
+
if (!callSem)
|
|
1944
|
+
continue;
|
|
1945
|
+
if (!semanticMatch(callSem, oldSelector, false))
|
|
1946
|
+
continue;
|
|
1947
|
+
if (this.isBlockedByStructuralWall(call, newSem))
|
|
1948
|
+
continue;
|
|
1949
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(call);
|
|
1950
|
+
const isChained = chainDepth > 0 || isLeafOfChain(call);
|
|
1951
|
+
if (!isChained)
|
|
1952
|
+
continue;
|
|
1953
|
+
const effectiveDepth = chainDepth > 0 ? chainDepth : 1;
|
|
1954
|
+
candidates.push({
|
|
1955
|
+
leafCall: call,
|
|
1956
|
+
chainTop,
|
|
1957
|
+
rootReceiver,
|
|
1958
|
+
chainDepth: effectiveDepth,
|
|
1959
|
+
distance: Math.abs(callLine - caller.line),
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
if (candidates.length === 0) {
|
|
1963
|
+
return {
|
|
1964
|
+
success: false,
|
|
1965
|
+
reason: "no chained candidates found",
|
|
1966
|
+
strategy: "F",
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
candidates.sort((a, b) => {
|
|
1970
|
+
if (b.chainDepth !== a.chainDepth)
|
|
1971
|
+
return b.chainDepth - a.chainDepth;
|
|
1972
|
+
return a.distance - b.distance;
|
|
1973
|
+
});
|
|
1974
|
+
for (const { leafCall, chainTop, rootReceiver, chainDepth } of candidates) {
|
|
1975
|
+
const newCallText = buildCallText(rootReceiver, newSem);
|
|
1976
|
+
try {
|
|
1977
|
+
chainTop.replaceWithText(newCallText);
|
|
1978
|
+
sourceFile.saveSync();
|
|
1979
|
+
return {
|
|
1980
|
+
success: true,
|
|
1981
|
+
reason: `chain collapsed atomically (depth=${chainDepth})`,
|
|
1982
|
+
strategy: "F",
|
|
1983
|
+
lineUpdated: leafCall.getStartLineNumber() - 1,
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
catch (e) {
|
|
1987
|
+
console.warn(`[ASTUpdater] ⚠️ F: replaceWithText failed: ${e.message}`);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return {
|
|
1991
|
+
success: false,
|
|
1992
|
+
reason: "chain collapse: all candidates threw",
|
|
1993
|
+
strategy: "F",
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
// ─────────────────────────────────────────────────────────────
|
|
1997
|
+
// STRATEGY G — Assertion Healing
|
|
1998
|
+
// ─────────────────────────────────────────────────────────────
|
|
1999
|
+
runStrategyG(sourceFile, caller, locatorVarName, contentChange) {
|
|
2000
|
+
if (!contentChange)
|
|
2001
|
+
return null;
|
|
2002
|
+
const callerLine = caller && caller.line > 0 ? caller.line : 1;
|
|
2003
|
+
const windowLines = caller === null || caller.line <= 0 ? 99999 : 40;
|
|
2004
|
+
const assertions = findAssertionsInWindow(sourceFile, locatorVarName || null, callerLine, windowLines);
|
|
2005
|
+
if (assertions.length === 0)
|
|
2006
|
+
return null;
|
|
2007
|
+
for (const assertion of assertions) {
|
|
2008
|
+
if (assertion.currentValue.includes(contentChange.oldText)) {
|
|
2009
|
+
const lineNum = assertion.line;
|
|
2010
|
+
const filePath = sourceFile.getFilePath();
|
|
2011
|
+
console.warn(`\n${"=".repeat(60)}`);
|
|
2012
|
+
console.warn(`⚠️ ASSERTION DRIFT DETECTED (Manual Review Required)`);
|
|
2013
|
+
console.warn(`📍 Location: ${path.basename(filePath)}:${lineNum}`);
|
|
2014
|
+
console.warn(`❌ Current: .toHaveText("${contentChange.oldText}")`);
|
|
2015
|
+
console.warn(`✅ Suggested: .toHaveText("${contentChange.newText}")`);
|
|
2016
|
+
console.warn(`💡 Reason: The element was found, but its content differs.`);
|
|
2017
|
+
console.warn(`${"=".repeat(60)}\n`);
|
|
2018
|
+
return {
|
|
2019
|
+
success: false,
|
|
2020
|
+
reason: `Assertion review required at line ${lineNum}`,
|
|
2021
|
+
strategy: "G",
|
|
2022
|
+
lineUpdated: undefined,
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return null;
|
|
2027
|
+
}
|
|
2028
|
+
// ─────────────────────────────────────────────────────────────
|
|
2029
|
+
// STRATEGY A — Frame Upstream Healing
|
|
2030
|
+
// ─────────────────────────────────────────────────────────────
|
|
2031
|
+
strategyFrameUpstream(sourceFile, caller, oldElementSelector, aiSegments, fullSelectorContext) {
|
|
2032
|
+
console.log(`[ASTUpdater] 🧠 Strategy A: Frame Upstream`);
|
|
2033
|
+
const originalSegments = fullSelectorContext
|
|
2034
|
+
.split(" >> ")
|
|
2035
|
+
.map((s) => s.trim());
|
|
2036
|
+
const frameSegments = aiSegments.filter((s) => s.type === "frame");
|
|
2037
|
+
const changedFrames = [];
|
|
2038
|
+
frameSegments.forEach((aiSeg, i) => {
|
|
2039
|
+
const orig = originalSegments[i];
|
|
2040
|
+
if (orig && aiSeg.selector !== orig) {
|
|
2041
|
+
changedFrames.push({ old: orig, new: aiSeg.selector });
|
|
2042
|
+
}
|
|
2043
|
+
});
|
|
2044
|
+
if (changedFrames.length === 0) {
|
|
2045
|
+
return { success: false, reason: "no frame changes", strategy: "A" };
|
|
2046
|
+
}
|
|
2047
|
+
const lines = sourceFile.getFullText().split(/\r?\n/);
|
|
2048
|
+
const failureLine = lines[caller.line - 1] ?? "";
|
|
2049
|
+
const varMatch = failureLine.match(/^\s*(?:await\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\.\s*(?:locator|getBy)/);
|
|
2050
|
+
const varName = varMatch?.[1];
|
|
2051
|
+
if (varName && varName !== "page") {
|
|
2052
|
+
const varDecl = jumpToDefinitionInFile(sourceFile, varName, caller.line);
|
|
2053
|
+
if (varDecl) {
|
|
2054
|
+
const declLine = varDecl.getStartLineNumber();
|
|
2055
|
+
let anyFixed = false;
|
|
2056
|
+
for (const change of changedFrames) {
|
|
2057
|
+
if (this.replaceFrameLocatorArg(varDecl, change.old, change.new)) {
|
|
2058
|
+
anyFixed = true;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (anyFixed) {
|
|
2062
|
+
sourceFile.saveSync();
|
|
2063
|
+
return {
|
|
2064
|
+
success: true,
|
|
2065
|
+
reason: `upstream variable '${varName}' fixed`,
|
|
2066
|
+
strategy: "A",
|
|
2067
|
+
lineUpdated: declLine - 1,
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
for (const change of changedFrames) {
|
|
2071
|
+
const result = this.replaceStringLiteralInRange(sourceFile, change.old, change.new, declLine, Math.min(sourceFile.getEndLineNumber(), declLine + 8));
|
|
2072
|
+
if (result !== null) {
|
|
2073
|
+
sourceFile.saveSync();
|
|
2074
|
+
return {
|
|
2075
|
+
success: true,
|
|
2076
|
+
reason: "multi-line frame fixed",
|
|
2077
|
+
strategy: "A",
|
|
2078
|
+
lineUpdated: result,
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
else {
|
|
2085
|
+
for (const change of changedFrames) {
|
|
2086
|
+
const result = this.replaceStringLiteralInRange(sourceFile, change.old, change.new, Math.max(1, caller.line - 15), caller.line);
|
|
2087
|
+
if (result !== null) {
|
|
2088
|
+
sourceFile.saveSync();
|
|
2089
|
+
return {
|
|
2090
|
+
success: true,
|
|
2091
|
+
reason: "inline frame fixed",
|
|
2092
|
+
strategy: "A",
|
|
2093
|
+
lineUpdated: result,
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return {
|
|
2099
|
+
success: false,
|
|
2100
|
+
reason: "frame upstream: no fix applied",
|
|
2101
|
+
strategy: "A",
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
// ─────────────────────────────────────────────────────────────
|
|
2105
|
+
// STRATEGY B — Deep Symbol Resolution
|
|
2106
|
+
// ─────────────────────────────────────────────────────────────
|
|
2107
|
+
strategyDeepSymbolResolution(sourceFile, caller, oldSelector, newSelector, oldSem, newSem, suspectIdentifier) {
|
|
2108
|
+
console.log(`[ASTUpdater] 🔭 Strategy B: Deep Symbol Resolution`);
|
|
2109
|
+
const failureNode = this.findNodeAtLine(sourceFile, caller.line);
|
|
2110
|
+
if (!failureNode)
|
|
2111
|
+
return {
|
|
2112
|
+
success: false,
|
|
2113
|
+
reason: "no node at failure line",
|
|
2114
|
+
strategy: "B",
|
|
2115
|
+
};
|
|
2116
|
+
const statement = this.getEnclosingStatement(failureNode);
|
|
2117
|
+
if (!statement)
|
|
2118
|
+
return {
|
|
2119
|
+
success: false,
|
|
2120
|
+
reason: "no enclosing statement",
|
|
2121
|
+
strategy: "B",
|
|
2122
|
+
};
|
|
2123
|
+
const usedVarNames = new Set();
|
|
2124
|
+
statement.getDescendantsOfKind(ts_morph_1.SyntaxKind.Identifier).forEach((id) => {
|
|
2125
|
+
const text = id.getText();
|
|
2126
|
+
if (text !== "page" &&
|
|
2127
|
+
text !== "await" &&
|
|
2128
|
+
text !== "expect" &&
|
|
2129
|
+
/^[a-zA-Z_$]/.test(text) &&
|
|
2130
|
+
!ALL_SELECTOR_METHODS.has(text) &&
|
|
2131
|
+
!this.WRAPPER_IDENTIFIERS.has(text)) {
|
|
2132
|
+
usedVarNames.add(text);
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
const sortedVars = [...usedVarNames].sort((a, b) => {
|
|
2136
|
+
if (a === suspectIdentifier)
|
|
2137
|
+
return -1;
|
|
2138
|
+
if (b === suspectIdentifier)
|
|
2139
|
+
return 1;
|
|
2140
|
+
return 0;
|
|
2141
|
+
});
|
|
2142
|
+
for (const varName of sortedVars) {
|
|
2143
|
+
const declaration = jumpToDefinitionInFile(sourceFile, varName, caller.line);
|
|
2144
|
+
if (!declaration)
|
|
2145
|
+
continue;
|
|
2146
|
+
const declLine = declaration.getStartLineNumber();
|
|
2147
|
+
const isSuspect = varName === suspectIdentifier;
|
|
2148
|
+
console.log(`[ASTUpdater] 🎯 JumpToDef: '${varName}' → line ${declLine} (suspect=${isSuspect})`);
|
|
2149
|
+
const initChain = analyzeVariableInitializerChain(declaration);
|
|
2150
|
+
if (initChain) {
|
|
2151
|
+
const initCalls = initChain.fullChainNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression);
|
|
2152
|
+
const hasMatch = initCalls.some((c) => {
|
|
2153
|
+
const cs = extractSemanticFromCall(c);
|
|
2154
|
+
return cs ? semanticMatch(cs, oldSelector, false) : false;
|
|
2155
|
+
});
|
|
2156
|
+
const outerSem = extractSemanticFromCall(initChain.fullChainNode);
|
|
2157
|
+
const outerMatch = outerSem
|
|
2158
|
+
? semanticMatch(outerSem, oldSelector, false)
|
|
2159
|
+
: false;
|
|
2160
|
+
if (hasMatch || outerMatch) {
|
|
2161
|
+
if (!isSuspect &&
|
|
2162
|
+
this.isBlockedByStructuralWall(initChain.fullChainNode, newSem))
|
|
2163
|
+
continue;
|
|
2164
|
+
const { chainTop, rootReceiver } = climbToChainTop(initChain.fullChainNode);
|
|
2165
|
+
const effectiveReceiver = rootReceiver || initChain.rootReceiver;
|
|
2166
|
+
const newCallText = buildCallText(effectiveReceiver, newSem);
|
|
2167
|
+
try {
|
|
2168
|
+
chainTop.replaceWithText(newCallText);
|
|
2169
|
+
sourceFile.saveSync();
|
|
2170
|
+
const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
|
|
2171
|
+
if (cascadePairs.length > 0) {
|
|
2172
|
+
sourceFile.saveSync();
|
|
2173
|
+
HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
|
|
2174
|
+
}
|
|
2175
|
+
return {
|
|
2176
|
+
success: true,
|
|
2177
|
+
reason: `initializer chain collapsed in '${varName}' at line ${declLine}`,
|
|
2178
|
+
strategy: "B",
|
|
2179
|
+
lineUpdated: declLine - 1,
|
|
2180
|
+
cascadeFixed: cascadePairs.length,
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
catch (e) {
|
|
2184
|
+
console.warn(`[ASTUpdater] ⚠️ B initializer-collapse failed: ${e.message}`);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
const chainResult = this.findMatchingChainTail(declaration, oldSelector, oldSem);
|
|
2189
|
+
if (!chainResult)
|
|
2190
|
+
continue;
|
|
2191
|
+
const { tailCall } = chainResult;
|
|
2192
|
+
const callSem = extractSemanticFromCall(tailCall);
|
|
2193
|
+
if (!isSuspect && this.isBlockedByStructuralWall(tailCall, newSem))
|
|
2194
|
+
continue;
|
|
2195
|
+
if (!isSuspect && callSem) {
|
|
2196
|
+
const exactMethodMatch = callSem.method === oldSem.method &&
|
|
2197
|
+
callSem.primaryArg === oldSem.primaryArg;
|
|
2198
|
+
const leafMatch = (() => {
|
|
2199
|
+
const oldLeaf = oldSelector.split(">>").pop().trim();
|
|
2200
|
+
const oldLeafSem = parseInternalSelector(oldLeaf);
|
|
2201
|
+
return (callSem.method === oldLeafSem.method &&
|
|
2202
|
+
callSem.primaryArg === oldLeafSem.primaryArg);
|
|
2203
|
+
})();
|
|
2204
|
+
if (!exactMethodMatch && !leafMatch)
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
if (isLeafOfChain(tailCall)) {
|
|
2208
|
+
const { chainTop: tailChainTop, rootReceiver } = climbToChainTop(tailCall);
|
|
2209
|
+
try {
|
|
2210
|
+
tailChainTop.replaceWithText(buildCallText(rootReceiver, newSem));
|
|
2211
|
+
sourceFile.saveSync();
|
|
2212
|
+
const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
|
|
2213
|
+
if (cascadePairs.length > 0) {
|
|
2214
|
+
sourceFile.saveSync();
|
|
2215
|
+
HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
|
|
2216
|
+
}
|
|
2217
|
+
return {
|
|
2218
|
+
success: true,
|
|
2219
|
+
reason: `tail chain collapsed in '${varName}' at line ${declLine}`,
|
|
2220
|
+
strategy: "B",
|
|
2221
|
+
lineUpdated: tailCall.getStartLineNumber() - 1,
|
|
2222
|
+
cascadeFixed: cascadePairs.length,
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
catch (e) {
|
|
2226
|
+
console.warn(`[ASTUpdater] ⚠️ B tail-chain-collapse failed: ${e.message}`);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
const fixed = this.replaceChainTailOnly(tailCall, newSem, newSelector);
|
|
2230
|
+
if (fixed) {
|
|
2231
|
+
sourceFile.saveSync();
|
|
2232
|
+
const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
|
|
2233
|
+
if (cascadePairs.length > 0) {
|
|
2234
|
+
sourceFile.saveSync();
|
|
2235
|
+
HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
success: true,
|
|
2239
|
+
reason: `chain tail replaced in '${varName}' at line ${declLine}`,
|
|
2240
|
+
strategy: "B",
|
|
2241
|
+
lineUpdated: tailCall.getStartLineNumber() - 1,
|
|
2242
|
+
cascadeFixed: cascadePairs.length,
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
for (const call of statement.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
2247
|
+
const callSem = extractSemanticFromCall(call);
|
|
2248
|
+
if (!callSem || !semanticMatch(callSem, oldSelector, false))
|
|
2249
|
+
continue;
|
|
2250
|
+
if (this.isBlockedByStructuralWall(call, newSem))
|
|
2251
|
+
continue;
|
|
2252
|
+
const { chainTop: stmtChainTop, rootReceiver, chainDepth, } = climbToChainTop(call);
|
|
2253
|
+
if (chainDepth > 0 || isLeafOfChain(call)) {
|
|
2254
|
+
try {
|
|
2255
|
+
stmtChainTop.replaceWithText(buildCallText(rootReceiver, newSem));
|
|
2256
|
+
sourceFile.saveSync();
|
|
2257
|
+
return {
|
|
2258
|
+
success: true,
|
|
2259
|
+
reason: "direct statement chain collapse",
|
|
2260
|
+
strategy: "B",
|
|
2261
|
+
lineUpdated: caller.line - 1,
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
catch { }
|
|
2265
|
+
}
|
|
2266
|
+
if (this.replaceChainTailOnly(call, newSem, newSelector)) {
|
|
2267
|
+
sourceFile.saveSync();
|
|
2268
|
+
const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
|
|
2269
|
+
if (cascadePairs.length > 0) {
|
|
2270
|
+
sourceFile.saveSync();
|
|
2271
|
+
HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
|
|
2272
|
+
}
|
|
2273
|
+
return {
|
|
2274
|
+
success: true,
|
|
2275
|
+
reason: "direct statement fix",
|
|
2276
|
+
strategy: "B",
|
|
2277
|
+
lineUpdated: caller.line - 1,
|
|
2278
|
+
cascadeFixed: cascadePairs.length,
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
return {
|
|
2283
|
+
success: false,
|
|
2284
|
+
reason: "symbol not resolved (semantic-aware)",
|
|
2285
|
+
strategy: "B",
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
// ─────────────────────────────────────────────────────────────
|
|
2289
|
+
// STRATEGY C — Inline Semantic Fix
|
|
2290
|
+
// ─────────────────────────────────────────────────────────────
|
|
2291
|
+
strategyInlineSemanticFix(sourceFile, caller, oldSelector, newSelector, oldSem, newSem) {
|
|
2292
|
+
console.log(`[ASTUpdater] 🎯 Strategy C: Inline Semantic Fix`);
|
|
2293
|
+
const startLine = Math.max(1, caller.line - 8);
|
|
2294
|
+
const endLine = Math.min(sourceFile.getEndLineNumber(), caller.line + 2);
|
|
2295
|
+
for (const call of this.findCallExpressionsInRange(sourceFile, startLine, endLine, ALL_SELECTOR_METHODS)) {
|
|
2296
|
+
const callSem = extractSemanticFromCall(call);
|
|
2297
|
+
if (!callSem || !semanticMatch(callSem, oldSelector))
|
|
2298
|
+
continue;
|
|
2299
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(call);
|
|
2300
|
+
if (chainDepth > 0 || isLeafOfChain(call)) {
|
|
2301
|
+
try {
|
|
2302
|
+
chainTop.replaceWithText(buildCallText(rootReceiver, newSem));
|
|
2303
|
+
const fixLine = chainTop.getStartLineNumber();
|
|
2304
|
+
sourceFile.saveSync();
|
|
2305
|
+
const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
|
|
2306
|
+
if (cascadePairs.length > 0) {
|
|
2307
|
+
sourceFile.saveSync();
|
|
2308
|
+
HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
|
|
2309
|
+
}
|
|
2310
|
+
return {
|
|
2311
|
+
success: true,
|
|
2312
|
+
reason: `inline chain collapse at line ${fixLine}`,
|
|
2313
|
+
strategy: "C",
|
|
2314
|
+
lineUpdated: fixLine - 1,
|
|
2315
|
+
cascadeFixed: cascadePairs.length,
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
catch { }
|
|
2319
|
+
}
|
|
2320
|
+
if (this.applySemanticFix(call, newSem, newSelector)) {
|
|
2321
|
+
const fixLine = call.getStartLineNumber();
|
|
2322
|
+
sourceFile.saveSync();
|
|
2323
|
+
const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
|
|
2324
|
+
if (cascadePairs.length > 0) {
|
|
2325
|
+
sourceFile.saveSync();
|
|
2326
|
+
HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
|
|
2327
|
+
}
|
|
2328
|
+
return {
|
|
2329
|
+
success: true,
|
|
2330
|
+
reason: `inline fix at line ${fixLine}`,
|
|
2331
|
+
strategy: "C",
|
|
2332
|
+
lineUpdated: fixLine - 1,
|
|
2333
|
+
cascadeFixed: cascadePairs.length,
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
const propAccessResult = this.fixPropertyAccessDefinition(sourceFile, caller, oldSelector, newSelector, oldSem, newSem);
|
|
2338
|
+
if (propAccessResult.success)
|
|
2339
|
+
return propAccessResult;
|
|
2340
|
+
return {
|
|
2341
|
+
success: false,
|
|
2342
|
+
reason: "no inline semantic match",
|
|
2343
|
+
strategy: "C",
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
// ─────────────────────────────────────────────────────────────
|
|
2347
|
+
// STRATEGY D — Template Literal
|
|
2348
|
+
// ─────────────────────────────────────────────────────────────
|
|
2349
|
+
strategyTemplateLiteral(sourceFile, caller, oldSelector, newSelector) {
|
|
2350
|
+
console.log(`[ASTUpdater] 🔤 Strategy D: Template Literal`);
|
|
2351
|
+
const oldAttrMatch = oldSelector.match(/\[([a-zA-Z-]+)="([^"]*)"\]/);
|
|
2352
|
+
if (!oldAttrMatch)
|
|
2353
|
+
return {
|
|
2354
|
+
success: false,
|
|
2355
|
+
reason: "oldSelector has no attribute",
|
|
2356
|
+
strategy: "D",
|
|
2357
|
+
};
|
|
2358
|
+
const [, attrName, attrValue] = oldAttrMatch;
|
|
2359
|
+
const newLeaf = newSelector.split(">>").pop().trim();
|
|
2360
|
+
const newAttrMatch = newLeaf.match(/\[([a-zA-Z-]+)="([^"]*)"\]/);
|
|
2361
|
+
if (!newAttrMatch || newAttrMatch[1] !== attrName) {
|
|
2362
|
+
return {
|
|
2363
|
+
success: false,
|
|
2364
|
+
reason: "attribute name mismatch",
|
|
2365
|
+
strategy: "D",
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
for (const tmpl of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.TemplateExpression)) {
|
|
2369
|
+
const fullText = tmpl.getFullText();
|
|
2370
|
+
if (!fullText.includes(attrName))
|
|
2371
|
+
continue;
|
|
2372
|
+
const staticSuffixMatch = fullText.match(/\$\{[^}]+\}([^"'`\]]*)\]/);
|
|
2373
|
+
if (!staticSuffixMatch)
|
|
2374
|
+
continue;
|
|
2375
|
+
const oldStaticPart = staticSuffixMatch[1];
|
|
2376
|
+
const varPart = attrValue.replace(new RegExp(oldStaticPart + "$"), "");
|
|
2377
|
+
const newAttrValue = newAttrMatch[2];
|
|
2378
|
+
const newStaticPart = newAttrValue.startsWith(varPart)
|
|
2379
|
+
? newAttrValue.slice(varPart.length)
|
|
2380
|
+
: "_" + newAttrValue.split("_").pop();
|
|
2381
|
+
if (oldStaticPart === newStaticPart)
|
|
2382
|
+
continue;
|
|
2383
|
+
const escapedOld = oldStaticPart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2384
|
+
const newText = fullText.replace(new RegExp(`(\\\$\\{[^}]+\\})${escapedOld}(\\])`), `$1${newStaticPart}$2`);
|
|
2385
|
+
if (newText !== fullText) {
|
|
2386
|
+
tmpl.replaceWithText(newText.trim());
|
|
2387
|
+
const line = tmpl.getStartLineNumber();
|
|
2388
|
+
sourceFile.saveSync();
|
|
2389
|
+
return {
|
|
2390
|
+
success: true,
|
|
2391
|
+
reason: "template literal suffix updated",
|
|
2392
|
+
strategy: "D",
|
|
2393
|
+
lineUpdated: line - 1,
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return {
|
|
2398
|
+
success: false,
|
|
2399
|
+
reason: "no matching template literal",
|
|
2400
|
+
strategy: "D",
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
// ─────────────────────────────────────────────────────────────
|
|
2404
|
+
// STRATEGY E — Semantic Fragment Scan
|
|
2405
|
+
// ─────────────────────────────────────────────────────────────
|
|
2406
|
+
strategySemanticFragmentScan(sourceFile, caller, oldSelector, newSelector, oldSem, newSem) {
|
|
2407
|
+
console.log(`[ASTUpdater] 🔎 Strategy E: Semantic Fragment Scan`);
|
|
2408
|
+
const candidates = [];
|
|
2409
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
2410
|
+
const callSem = extractSemanticFromCall(call);
|
|
2411
|
+
if (!callSem || !semanticMatch(callSem, oldSelector, true))
|
|
2412
|
+
continue;
|
|
2413
|
+
candidates.push({
|
|
2414
|
+
call,
|
|
2415
|
+
callSem,
|
|
2416
|
+
distance: Math.abs(call.getStartLineNumber() - caller.line),
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
if (candidates.length === 0)
|
|
2420
|
+
return {
|
|
2421
|
+
success: false,
|
|
2422
|
+
reason: "no fragment candidates",
|
|
2423
|
+
strategy: "E",
|
|
2424
|
+
};
|
|
2425
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
2426
|
+
for (const { call, callSem } of candidates) {
|
|
2427
|
+
if (this.isBlockedByStructuralWall(call, newSem))
|
|
2428
|
+
continue;
|
|
2429
|
+
const { chainTop, rootReceiver, chainDepth } = climbToChainTop(call);
|
|
2430
|
+
if (chainDepth > 0 || isLeafOfChain(call)) {
|
|
2431
|
+
try {
|
|
2432
|
+
chainTop.replaceWithText(buildCallText(rootReceiver, newSem));
|
|
2433
|
+
sourceFile.saveSync();
|
|
2434
|
+
return {
|
|
2435
|
+
success: true,
|
|
2436
|
+
reason: `fragment scan chain collapse .${callSem.method}() @ line ${call.getStartLineNumber()}`,
|
|
2437
|
+
strategy: "E",
|
|
2438
|
+
lineUpdated: call.getStartLineNumber() - 1,
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
catch { }
|
|
2442
|
+
}
|
|
2443
|
+
if (this.replaceChainTailOnly(call, newSem, newSelector)) {
|
|
2444
|
+
sourceFile.saveSync();
|
|
2445
|
+
return {
|
|
2446
|
+
success: true,
|
|
2447
|
+
reason: `fragment scan fixed .${callSem.method}() @ line ${call.getStartLineNumber()}`,
|
|
2448
|
+
strategy: "E",
|
|
2449
|
+
lineUpdated: call.getStartLineNumber() - 1,
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
return {
|
|
2454
|
+
success: false,
|
|
2455
|
+
reason: "fragment scan: all candidates blocked",
|
|
2456
|
+
strategy: "E",
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2460
|
+
// CHAIN HELPERS
|
|
2461
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2462
|
+
findMatchingChainTail(decl, oldSelector, oldSem) {
|
|
2463
|
+
for (const call of decl.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
2464
|
+
const callSem = extractSemanticFromCall(call);
|
|
2465
|
+
if (!callSem || !semanticMatch(callSem, oldSelector, false))
|
|
2466
|
+
continue;
|
|
2467
|
+
let outerCall = call;
|
|
2468
|
+
let parent = call.getParent();
|
|
2469
|
+
while (parent) {
|
|
2470
|
+
if (ts_morph_1.Node.isCallExpression(parent)) {
|
|
2471
|
+
outerCall = parent;
|
|
2472
|
+
parent = parent.getParent();
|
|
2473
|
+
}
|
|
2474
|
+
else if (ts_morph_1.Node.isPropertyAccessExpression(parent) ||
|
|
2475
|
+
ts_morph_1.Node.isAwaitExpression(parent)) {
|
|
2476
|
+
parent = parent.getParent();
|
|
2477
|
+
}
|
|
2478
|
+
else {
|
|
2479
|
+
break;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
return { outerCall, tailCall: call };
|
|
2483
|
+
}
|
|
2484
|
+
return null;
|
|
2485
|
+
}
|
|
2486
|
+
replaceChainTailOnly(tailCall, newSem, _newSelector) {
|
|
2487
|
+
const expr = tailCall.getExpression();
|
|
2488
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
2489
|
+
return false;
|
|
2490
|
+
const receiver = expr.getExpression().getText();
|
|
2491
|
+
const newCallText = buildCallText(receiver, newSem);
|
|
2492
|
+
try {
|
|
2493
|
+
tailCall.replaceWithText(newCallText);
|
|
2494
|
+
return true;
|
|
2495
|
+
}
|
|
2496
|
+
catch (e) {
|
|
2497
|
+
console.warn(`[ASTUpdater] ⚠️ replaceChainTailOnly failed: ${e.message}`);
|
|
2498
|
+
return false;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2502
|
+
// SEMANTIC CORE OPERATIONS
|
|
2503
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2504
|
+
applySemanticFix(callExpr, newSem, newSelector) {
|
|
2505
|
+
const expr = callExpr.getExpression();
|
|
2506
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
2507
|
+
return false;
|
|
2508
|
+
const currentMethod = expr.getName();
|
|
2509
|
+
const receiver = expr.getExpression().getText();
|
|
2510
|
+
if (currentMethod === newSem.method)
|
|
2511
|
+
return this.replaceCallArgument(callExpr, newSem);
|
|
2512
|
+
const newCallText = buildCallText(receiver, newSem);
|
|
2513
|
+
try {
|
|
2514
|
+
callExpr.replaceWithText(newCallText);
|
|
2515
|
+
return true;
|
|
2516
|
+
}
|
|
2517
|
+
catch (e) {
|
|
2518
|
+
console.warn(`[ASTUpdater] ⚠️ applySemanticFix failed: ${e.message}`);
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* Guard: returns false when replacing oldNode with proposedNewText would
|
|
2524
|
+
* substitute a hardcoded string literal for an Identifier or
|
|
2525
|
+
* PropertyAccessExpression — which is the definition-site mutation bug.
|
|
2526
|
+
* All other replacements are allowed.
|
|
2527
|
+
*/
|
|
2528
|
+
static isSafeReplacement(oldNode, proposedNewText) {
|
|
2529
|
+
if (ts_morph_1.Node.isIdentifier(oldNode) ||
|
|
2530
|
+
ts_morph_1.Node.isPropertyAccessExpression(oldNode)) {
|
|
2531
|
+
return !/^["'`]/.test(proposedNewText.trim());
|
|
2532
|
+
}
|
|
2533
|
+
return true;
|
|
2534
|
+
}
|
|
2535
|
+
replaceCallArgument(callExpr, newSem) {
|
|
2536
|
+
try {
|
|
2537
|
+
const args = callExpr.getArguments();
|
|
2538
|
+
if (args.length === 0)
|
|
2539
|
+
return false;
|
|
2540
|
+
const firstArg = args[0];
|
|
2541
|
+
const safe = newSem.primaryArg.replace(/"/g, "'");
|
|
2542
|
+
if (ts_morph_1.Node.isStringLiteral(firstArg)) {
|
|
2543
|
+
firstArg.setLiteralValue(safe);
|
|
2544
|
+
}
|
|
2545
|
+
else if (ASTSourceUpdater.isSafeReplacement(firstArg, `"${safe}"`)) {
|
|
2546
|
+
firstArg.replaceWithText(`"${safe}"`);
|
|
2547
|
+
}
|
|
2548
|
+
else {
|
|
2549
|
+
console.warn(`[ASTUpdater] ⚠️ replaceCallArgument: blocked unsafe replacement — ` +
|
|
2550
|
+
`arg is ${firstArg.getKindName()}, not a string literal. ` +
|
|
2551
|
+
`Heal at definition site instead.`);
|
|
2552
|
+
return false;
|
|
2553
|
+
}
|
|
2554
|
+
if (newSem.options && Object.keys(newSem.options).length > 0) {
|
|
2555
|
+
const optionsStr = Object.entries(newSem.options)
|
|
2556
|
+
.map(([k, v]) => typeof v === "string" ? `${k}: "${v}"` : `${k}: ${v}`)
|
|
2557
|
+
.join(", ");
|
|
2558
|
+
if (args.length >= 2)
|
|
2559
|
+
args[1].replaceWithText(`{ ${optionsStr} }`);
|
|
2560
|
+
else
|
|
2561
|
+
callExpr.addArgument(`{ ${optionsStr} }`);
|
|
2562
|
+
}
|
|
2563
|
+
return true;
|
|
2564
|
+
}
|
|
2565
|
+
catch (e) {
|
|
2566
|
+
console.warn(`[ASTUpdater] ⚠️ replaceCallArgument failed: ${e.message}`);
|
|
2567
|
+
return false;
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
semanticToInternal(sem) {
|
|
2571
|
+
switch (sem.method) {
|
|
2572
|
+
case "getByLabel":
|
|
2573
|
+
return `internal:label=${JSON.stringify(sem.primaryArg)}`;
|
|
2574
|
+
case "getByRole": {
|
|
2575
|
+
const namePart = sem.options?.name
|
|
2576
|
+
? `[name="${sem.options.name}"i]`
|
|
2577
|
+
: "";
|
|
2578
|
+
return `internal:role=${sem.primaryArg}${namePart}`;
|
|
2579
|
+
}
|
|
2580
|
+
case "getByPlaceholder":
|
|
2581
|
+
return `internal:attr=[placeholder=${JSON.stringify(sem.primaryArg)}]`;
|
|
2582
|
+
case "getByText": {
|
|
2583
|
+
const exact = sem.options?.exact ? "" : "i";
|
|
2584
|
+
return `internal:text=${JSON.stringify(sem.primaryArg)}${exact}`;
|
|
2585
|
+
}
|
|
2586
|
+
case "getByAltText":
|
|
2587
|
+
return `internal:attr=[alt=${JSON.stringify(sem.primaryArg)}]`;
|
|
2588
|
+
case "getByTitle":
|
|
2589
|
+
return `internal:attr=[title=${JSON.stringify(sem.primaryArg)}]`;
|
|
2590
|
+
case "getByTestId":
|
|
2591
|
+
return `[data-testid="${sem.primaryArg}"]`;
|
|
2592
|
+
default:
|
|
2593
|
+
return sem.primaryArg;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem) {
|
|
2597
|
+
const fixed = [];
|
|
2598
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
2599
|
+
try {
|
|
2600
|
+
const callSem = extractSemanticFromCall(call);
|
|
2601
|
+
if (!callSem || !semanticMatch(callSem, oldSelector, true))
|
|
2602
|
+
continue;
|
|
2603
|
+
const callInternal = this.semanticToInternal(callSem);
|
|
2604
|
+
if (this.applySemanticFix(call, newSem, newSelector)) {
|
|
2605
|
+
fixed.push({ oldSelector: callInternal ?? oldSelector, newSelector });
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
catch { }
|
|
2609
|
+
}
|
|
2610
|
+
return fixed;
|
|
2611
|
+
}
|
|
2612
|
+
fixPropertyAccessDefinition(sourceFile, caller, oldSelector, newSelector, oldSem, newSem) {
|
|
2613
|
+
for (const callExpr of this.findCallExpressionsInRange(sourceFile, Math.max(1, caller.line - 5), caller.line + 2, ALL_SELECTOR_METHODS)) {
|
|
2614
|
+
const firstArg = callExpr.getArguments()[0];
|
|
2615
|
+
if (!firstArg || !ts_morph_1.Node.isPropertyAccessExpression(firstArg))
|
|
2616
|
+
continue;
|
|
2617
|
+
const objName = firstArg.getExpression().getText();
|
|
2618
|
+
const propName = firstArg.getName();
|
|
2619
|
+
const result = this.updateObjectProperty(sourceFile, objName, propName, oldSem.primaryArg, newSem.primaryArg);
|
|
2620
|
+
if (result !== null) {
|
|
2621
|
+
sourceFile.saveSync();
|
|
2622
|
+
return {
|
|
2623
|
+
success: true,
|
|
2624
|
+
reason: `'${objName}.${propName}' updated`,
|
|
2625
|
+
strategy: "C",
|
|
2626
|
+
lineUpdated: result,
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
return { success: false, reason: "no property access", strategy: "C" };
|
|
2631
|
+
}
|
|
2632
|
+
isBlockedByStructuralWall(currentCall, newSem) {
|
|
2633
|
+
const newIsInteractive = (newSem.method === "getByRole" &&
|
|
2634
|
+
this.INTERACTIVE_ROLES.has(newSem.primaryArg.toLowerCase())) ||
|
|
2635
|
+
newSem.method === "getByLabel" ||
|
|
2636
|
+
newSem.method === "getByPlaceholder" ||
|
|
2637
|
+
(newSem.method === "locator" &&
|
|
2638
|
+
(newSem.primaryArg.startsWith("#") ||
|
|
2639
|
+
newSem.primaryArg.includes("input") ||
|
|
2640
|
+
newSem.primaryArg.includes("textarea")));
|
|
2641
|
+
if (!newIsInteractive)
|
|
2642
|
+
return false;
|
|
2643
|
+
const callSem = extractSemanticFromCall(currentCall);
|
|
2644
|
+
if (!callSem)
|
|
2645
|
+
return false;
|
|
2646
|
+
if (callSem.method === "getByRole" &&
|
|
2647
|
+
this.STRUCTURAL_ROLES.has(callSem.primaryArg.toLowerCase())) {
|
|
2648
|
+
console.warn(`[ASTUpdater] 🧱 Structural Wall BLOCKED: ` +
|
|
2649
|
+
`.getByRole("${callSem.primaryArg}") is structural, ` +
|
|
2650
|
+
`refusing to replace with ${newSem.method}("${newSem.primaryArg}")`);
|
|
2651
|
+
return true;
|
|
2652
|
+
}
|
|
2653
|
+
return false;
|
|
2654
|
+
}
|
|
2655
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2656
|
+
// AST NAVIGATION HELPERS
|
|
2657
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2658
|
+
identifySuspectIdentifier(statement, line) {
|
|
2659
|
+
const callsOnLine = statement
|
|
2660
|
+
.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)
|
|
2661
|
+
.filter((c) => c.getStartLineNumber() <= line && c.getEndLineNumber() >= line);
|
|
2662
|
+
if (callsOnLine.length === 0)
|
|
2663
|
+
return null;
|
|
2664
|
+
let bestIdentifier = null;
|
|
2665
|
+
let bestDepth = -1;
|
|
2666
|
+
for (const call of callsOnLine) {
|
|
2667
|
+
const expr = call.getExpression();
|
|
2668
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
2669
|
+
continue;
|
|
2670
|
+
let current = expr.getExpression();
|
|
2671
|
+
let depth = 0;
|
|
2672
|
+
while (ts_morph_1.Node.isCallExpression(current)) {
|
|
2673
|
+
const inner = current.getExpression();
|
|
2674
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(inner))
|
|
2675
|
+
break;
|
|
2676
|
+
current = inner.getExpression();
|
|
2677
|
+
depth++;
|
|
2678
|
+
}
|
|
2679
|
+
while (ts_morph_1.Node.isPropertyAccessExpression(current)) {
|
|
2680
|
+
current = current.getExpression();
|
|
2681
|
+
depth++;
|
|
2682
|
+
}
|
|
2683
|
+
if (!ts_morph_1.Node.isIdentifier(current))
|
|
2684
|
+
continue;
|
|
2685
|
+
const name = current.getText();
|
|
2686
|
+
if (this.WRAPPER_IDENTIFIERS.has(name))
|
|
2687
|
+
continue;
|
|
2688
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name))
|
|
2689
|
+
continue;
|
|
2690
|
+
if (depth > bestDepth) {
|
|
2691
|
+
bestDepth = depth;
|
|
2692
|
+
bestIdentifier = name;
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
return bestIdentifier;
|
|
2696
|
+
}
|
|
2697
|
+
getEnclosingStatement(node) {
|
|
2698
|
+
return (node.getFirstAncestorByKind(ts_morph_1.SyntaxKind.ExpressionStatement) ??
|
|
2699
|
+
node.getFirstAncestorByKind(ts_morph_1.SyntaxKind.VariableStatement) ??
|
|
2700
|
+
node.getParentWhile((n) => !ts_morph_1.Node.isBlock(n) && !ts_morph_1.Node.isSourceFile(n)));
|
|
2701
|
+
}
|
|
2702
|
+
findNodeAtLine(sourceFile, line) {
|
|
2703
|
+
const targetOffset = this.lineToOffset(sourceFile, line);
|
|
2704
|
+
const endOffset = this.lineToOffset(sourceFile, line + 1);
|
|
2705
|
+
return sourceFile.getDescendants().find((n) => {
|
|
2706
|
+
const start = n.getStart();
|
|
2707
|
+
return start >= targetOffset && start < endOffset;
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
findCallExpressionsInRange(sourceFile, startLine, endLine, methodNames) {
|
|
2711
|
+
return sourceFile
|
|
2712
|
+
.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)
|
|
2713
|
+
.filter((call) => {
|
|
2714
|
+
const line = call.getStartLineNumber();
|
|
2715
|
+
if (line < startLine || line > endLine)
|
|
2716
|
+
return false;
|
|
2717
|
+
const expr = call.getExpression();
|
|
2718
|
+
return (ts_morph_1.Node.isPropertyAccessExpression(expr) &&
|
|
2719
|
+
methodNames.has(expr.getName()));
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
replaceFrameLocatorArg(varDecl, oldFrame, newFrame) {
|
|
2723
|
+
for (const call of varDecl.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
2724
|
+
const expr = call.getExpression();
|
|
2725
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr) ||
|
|
2726
|
+
expr.getName() !== "frameLocator")
|
|
2727
|
+
continue;
|
|
2728
|
+
const arg = call.getArguments()[0];
|
|
2729
|
+
if (arg &&
|
|
2730
|
+
ts_morph_1.Node.isStringLiteral(arg) &&
|
|
2731
|
+
arg.getLiteralValue() === oldFrame) {
|
|
2732
|
+
arg.setLiteralValue(newFrame);
|
|
2733
|
+
return true;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
return false;
|
|
2737
|
+
}
|
|
2738
|
+
replaceStringLiteralInRange(sourceFile, oldVal, newVal, startLine, endLine) {
|
|
2739
|
+
const startOffset = this.lineToOffset(sourceFile, startLine);
|
|
2740
|
+
const endOffset = this.lineToOffset(sourceFile, endLine + 1);
|
|
2741
|
+
for (const node of [
|
|
2742
|
+
...sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.StringLiteral),
|
|
2743
|
+
...sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.NoSubstitutionTemplateLiteral),
|
|
2744
|
+
]) {
|
|
2745
|
+
const offset = node.getStart();
|
|
2746
|
+
if (offset < startOffset || offset > endOffset)
|
|
2747
|
+
continue;
|
|
2748
|
+
const val = node.getLiteralValue?.() ??
|
|
2749
|
+
node.getLiteralValue?.() ??
|
|
2750
|
+
"";
|
|
2751
|
+
if (val === oldVal) {
|
|
2752
|
+
if (ts_morph_1.Node.isStringLiteral(node)) {
|
|
2753
|
+
node.setLiteralValue(newVal.replace(/"/g, "'"));
|
|
2754
|
+
}
|
|
2755
|
+
else {
|
|
2756
|
+
node.replaceWithText(`\`${newVal}\``);
|
|
2757
|
+
}
|
|
2758
|
+
return node.getStartLineNumber() - 1;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return null;
|
|
2762
|
+
}
|
|
2763
|
+
updateObjectProperty(sourceFile, objName, propName, oldValue, newValue) {
|
|
2764
|
+
for (const decl of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)) {
|
|
2765
|
+
if (decl.getName() !== objName)
|
|
2766
|
+
continue;
|
|
2767
|
+
const init = decl.getInitializer();
|
|
2768
|
+
if (!init || !ts_morph_1.Node.isObjectLiteralExpression(init))
|
|
2769
|
+
continue;
|
|
2770
|
+
for (const prop of init.getProperties()) {
|
|
2771
|
+
if (!ts_morph_1.Node.isPropertyAssignment(prop) || prop.getName() !== propName)
|
|
2772
|
+
continue;
|
|
2773
|
+
const valueNode = prop.getInitializer();
|
|
2774
|
+
if (!valueNode || !ts_morph_1.Node.isStringLiteral(valueNode))
|
|
2775
|
+
continue;
|
|
2776
|
+
if (valueNode.getLiteralValue() === oldValue) {
|
|
2777
|
+
valueNode.setLiteralValue(newValue.replace(/"/g, "'"));
|
|
2778
|
+
return valueNode.getStartLineNumber() - 1;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
return null;
|
|
2783
|
+
}
|
|
2784
|
+
lineToOffset(sourceFile, line) {
|
|
2785
|
+
const text = sourceFile.getFullText();
|
|
2786
|
+
const lines = text.split("\n");
|
|
2787
|
+
let offset = 0;
|
|
2788
|
+
for (let i = 0; i < Math.min(line - 1, lines.length); i++) {
|
|
2789
|
+
offset += lines[i].length + 1;
|
|
2790
|
+
}
|
|
2791
|
+
return offset;
|
|
2792
|
+
}
|
|
2793
|
+
resolveFilePath(raw) {
|
|
2794
|
+
let clean = raw
|
|
2795
|
+
.replace(/^.*?at\s+/, "")
|
|
2796
|
+
.replace(/:\d+:\d+.*$/, "")
|
|
2797
|
+
.trim();
|
|
2798
|
+
if (!path.isAbsolute(clean))
|
|
2799
|
+
clean = path.resolve(process.cwd(), clean);
|
|
2800
|
+
return fs.existsSync(clean) ? clean : null;
|
|
2801
|
+
}
|
|
2802
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2803
|
+
// T-10 / T-11: Semantic Dispatch Gate + handlers
|
|
2804
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2805
|
+
semanticDispatch(sourceFile, caller, oldSelector, newSelector) {
|
|
2806
|
+
try {
|
|
2807
|
+
// Expand window: the failure site (caller.line) is often .click()/.fill()
|
|
2808
|
+
// AFTER the locator assignment. Search up to 5 lines back so we catch
|
|
2809
|
+
// page.locator(IDENTIFIER) even when it lives on the previous line.
|
|
2810
|
+
const calls = this.findCallExpressionsInRange(sourceFile, Math.max(1, caller.line - 5), caller.line, ALL_SELECTOR_METHODS);
|
|
2811
|
+
for (const call of calls) {
|
|
2812
|
+
const analysis = ArgumentTypeAnalyzer_1.ArgumentTypeAnalyzer.classify(call);
|
|
2813
|
+
if (!analysis)
|
|
2814
|
+
continue;
|
|
2815
|
+
const { shape, node } = analysis;
|
|
2816
|
+
if (shape === "STRING_LITERAL" || shape === "COMPLEX")
|
|
2817
|
+
continue;
|
|
2818
|
+
let result = null;
|
|
2819
|
+
if (shape === "IDENTIFIER") {
|
|
2820
|
+
result = this.handleIdentifierArg(node, sourceFile, caller, newSelector);
|
|
2821
|
+
}
|
|
2822
|
+
else if (shape === "PROPERTY_ACCESS") {
|
|
2823
|
+
result = this.handlePropertyAccessArg(node, sourceFile, caller, newSelector);
|
|
2824
|
+
}
|
|
2825
|
+
else if (shape === "TEMPLATE_LITERAL") {
|
|
2826
|
+
result = this.handleTemplateLiteralArg(node, sourceFile, caller, oldSelector, newSelector);
|
|
2827
|
+
}
|
|
2828
|
+
else if (shape === "CALL_EXPRESSION") {
|
|
2829
|
+
result = this.handleCallExpressionArg(node, sourceFile, oldSelector, newSelector);
|
|
2830
|
+
}
|
|
2831
|
+
// Hard exit: any result (success OR failure) from a definition-site shape
|
|
2832
|
+
// stops all subsequent strategies. Returning non-null prevents NT, H, and
|
|
2833
|
+
// H-partial from touching the call site when the arg is a symbolic reference.
|
|
2834
|
+
if (result !== null)
|
|
2835
|
+
return result;
|
|
2836
|
+
// Handler returned null (could not trace). For definition-site shapes,
|
|
2837
|
+
// return a hard-block failure so NT/H cannot overwrite the identifier.
|
|
2838
|
+
if (shape === "IDENTIFIER" ||
|
|
2839
|
+
shape === "PROPERTY_ACCESS" ||
|
|
2840
|
+
shape === "TEMPLATE_LITERAL") {
|
|
2841
|
+
console.log(`[SemanticDispatch] 🛑 hard-block: "${node.getText()}" is ${shape} ` +
|
|
2842
|
+
`but trace failed — preserving call site`);
|
|
2843
|
+
return {
|
|
2844
|
+
success: false,
|
|
2845
|
+
reason: `semantic guard: could not trace "${node.getText()}" — call site preserved`,
|
|
2846
|
+
strategy: "semantic-guard",
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
catch (e) {
|
|
2853
|
+
console.warn(`[SemanticDispatch] ⚠️ fell through: ${e.message}`);
|
|
2854
|
+
return null;
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
handleIdentifierArg(node, sourceFile, caller, newSelector) {
|
|
2858
|
+
const traceResult = DefinitionTracer_1.DefinitionTracer.traceIdentifier(node, sourceFile, caller.line);
|
|
2859
|
+
if (traceResult.found) {
|
|
2860
|
+
const { targetNode, targetFile, declarationNode } = traceResult;
|
|
2861
|
+
const blastNode = declarationNode ?? targetNode;
|
|
2862
|
+
const blast = BlastRadiusAnalyzer_1.BlastRadiusAnalyzer.analyze(blastNode, sourceFile, this.project);
|
|
2863
|
+
console.log(`[SemanticDispatch] 🔍 BlastRadius IDENTIFIER: risk=${blast.risk} ` +
|
|
2864
|
+
`(${blast.testFunctionCount} tests, ${blast.affectedFiles.length} files)`);
|
|
2865
|
+
if (blast.risk === "high") {
|
|
2866
|
+
this.advisoryBuffer.push({
|
|
2867
|
+
code: HealingAdvisory_1.AdvisoryCode.BLAST_RADIUS_BLOCKED,
|
|
2868
|
+
message: `Mutation blocked — ${blast.testFunctionCount} tests reference this constant.`,
|
|
2869
|
+
affectedFile: targetFile.getFilePath(),
|
|
2870
|
+
affectedLine: targetNode.getStartLineNumber() - 1,
|
|
2871
|
+
suggestedAction: "Review all referencing tests before changing this constant.",
|
|
2872
|
+
});
|
|
2873
|
+
return {
|
|
2874
|
+
success: false,
|
|
2875
|
+
reason: `BlastRadius BLOCKED: ${blast.testFunctionCount} tests reference this constant — manual review required`,
|
|
2876
|
+
strategy: "semantic-identifier",
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
if (blast.risk === "medium") {
|
|
2880
|
+
this.advisoryBuffer.push({
|
|
2881
|
+
code: HealingAdvisory_1.AdvisoryCode.BLAST_RADIUS_WARNING,
|
|
2882
|
+
message: `Mutated constant used in ${blast.testFunctionCount} tests — verify no regressions.`,
|
|
2883
|
+
affectedFile: targetFile.getFilePath(),
|
|
2884
|
+
affectedLine: targetNode.getStartLineNumber() - 1,
|
|
2885
|
+
suggestedAction: "Run the full test suite to confirm all references still pass.",
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
const result = InitializerUpdater_1.InitializerUpdater.apply(targetNode, targetFile, newSelector);
|
|
2889
|
+
if (!result)
|
|
2890
|
+
return null;
|
|
2891
|
+
if (!this.validateFileSyntax(targetFile)) {
|
|
2892
|
+
return {
|
|
2893
|
+
success: false,
|
|
2894
|
+
reason: "post-mutation syntax validation failed — reverted",
|
|
2895
|
+
strategy: "semantic-identifier",
|
|
2896
|
+
};
|
|
2897
|
+
}
|
|
2898
|
+
return {
|
|
2899
|
+
success: true,
|
|
2900
|
+
reason: `definition-site heal via identifier trace`,
|
|
2901
|
+
strategy: "semantic-identifier",
|
|
2902
|
+
lineUpdated: result.lineUpdated,
|
|
2903
|
+
healedLocatorString: result.healedLocatorString,
|
|
2904
|
+
definitionSite: { file: targetFile.getFilePath(), line: result.lineUpdated },
|
|
2905
|
+
blastRadius: blast.testFunctionCount,
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2908
|
+
if (!traceResult.found && traceResult.crossFile) {
|
|
2909
|
+
const xResult = CrossFileHealer_1.CrossFileHealer.heal(traceResult.modulePath, traceResult.symbolName, newSelector, caller.filePath, this.project);
|
|
2910
|
+
if (xResult.success) {
|
|
2911
|
+
this.advisoryBuffer.push({
|
|
2912
|
+
code: HealingAdvisory_1.AdvisoryCode.CROSS_FILE_MUTATION,
|
|
2913
|
+
message: `Definition-site mutation crossed file boundary to "${traceResult.modulePath}".`,
|
|
2914
|
+
affectedFile: caller.filePath,
|
|
2915
|
+
suggestedAction: "Verify no other callers of this symbol were affected.",
|
|
2916
|
+
});
|
|
2917
|
+
}
|
|
2918
|
+
return xResult;
|
|
2919
|
+
}
|
|
2920
|
+
return null;
|
|
2921
|
+
}
|
|
2922
|
+
handlePropertyAccessArg(node, sourceFile, caller, newSelector) {
|
|
2923
|
+
const traceResult = DefinitionTracer_1.DefinitionTracer.tracePropertyAccess(node, sourceFile, caller.line);
|
|
2924
|
+
if (traceResult.found) {
|
|
2925
|
+
const { targetNode, targetFile, declarationNode } = traceResult;
|
|
2926
|
+
const blastNode = declarationNode ?? targetNode;
|
|
2927
|
+
const blast = BlastRadiusAnalyzer_1.BlastRadiusAnalyzer.analyze(blastNode, sourceFile, this.project);
|
|
2928
|
+
console.log(`[SemanticDispatch] 🔍 BlastRadius PROPERTY_ACCESS: risk=${blast.risk} ` +
|
|
2929
|
+
`(${blast.testFunctionCount} tests, ${blast.affectedFiles.length} files)`);
|
|
2930
|
+
if (blast.risk === "high") {
|
|
2931
|
+
this.advisoryBuffer.push({
|
|
2932
|
+
code: HealingAdvisory_1.AdvisoryCode.BLAST_RADIUS_BLOCKED,
|
|
2933
|
+
message: `Mutation blocked — ${blast.testFunctionCount} tests reference this property.`,
|
|
2934
|
+
affectedFile: targetFile.getFilePath(),
|
|
2935
|
+
affectedLine: targetNode.getStartLineNumber() - 1,
|
|
2936
|
+
suggestedAction: "Review all referencing tests before changing this property.",
|
|
2937
|
+
});
|
|
2938
|
+
return {
|
|
2939
|
+
success: false,
|
|
2940
|
+
reason: `BlastRadius BLOCKED: ${blast.testFunctionCount} tests reference this property — manual review required`,
|
|
2941
|
+
strategy: "semantic-property-access",
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
if (blast.risk === "medium") {
|
|
2945
|
+
this.advisoryBuffer.push({
|
|
2946
|
+
code: HealingAdvisory_1.AdvisoryCode.BLAST_RADIUS_WARNING,
|
|
2947
|
+
message: `Mutated property used in ${blast.testFunctionCount} tests — verify no regressions.`,
|
|
2948
|
+
affectedFile: targetFile.getFilePath(),
|
|
2949
|
+
affectedLine: targetNode.getStartLineNumber() - 1,
|
|
2950
|
+
suggestedAction: "Run the full test suite to confirm all references still pass.",
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
const result = InitializerUpdater_1.InitializerUpdater.apply(targetNode, targetFile, newSelector);
|
|
2954
|
+
if (!result)
|
|
2955
|
+
return null;
|
|
2956
|
+
if (!this.validateFileSyntax(targetFile)) {
|
|
2957
|
+
return {
|
|
2958
|
+
success: false,
|
|
2959
|
+
reason: "post-mutation syntax validation failed — reverted",
|
|
2960
|
+
strategy: "semantic-property-access",
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
return {
|
|
2964
|
+
success: true,
|
|
2965
|
+
reason: `definition-site heal via property access trace`,
|
|
2966
|
+
strategy: "semantic-property-access",
|
|
2967
|
+
lineUpdated: result.lineUpdated,
|
|
2968
|
+
healedLocatorString: result.healedLocatorString,
|
|
2969
|
+
definitionSite: { file: targetFile.getFilePath(), line: result.lineUpdated },
|
|
2970
|
+
blastRadius: blast.testFunctionCount,
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
if (!traceResult.found && traceResult.crossFile) {
|
|
2974
|
+
const xResult = CrossFileHealer_1.CrossFileHealer.heal(traceResult.modulePath, traceResult.symbolName, newSelector, caller.filePath, this.project);
|
|
2975
|
+
if (xResult.success) {
|
|
2976
|
+
this.advisoryBuffer.push({
|
|
2977
|
+
code: HealingAdvisory_1.AdvisoryCode.CROSS_FILE_MUTATION,
|
|
2978
|
+
message: `Definition-site mutation crossed file boundary to "${traceResult.modulePath}".`,
|
|
2979
|
+
affectedFile: caller.filePath,
|
|
2980
|
+
suggestedAction: "Verify no other callers of this symbol were affected.",
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
return xResult;
|
|
2984
|
+
}
|
|
2985
|
+
return null;
|
|
2986
|
+
}
|
|
2987
|
+
handleTemplateLiteralArg(node, sourceFile, caller, oldSelector, newSelector) {
|
|
2988
|
+
if (!ts_morph_1.Node.isTemplateExpression(node))
|
|
2989
|
+
return null;
|
|
2990
|
+
const patchResult = TemplateDiffService_1.TemplateDiffService.computeNodePatch(node, oldSelector, newSelector);
|
|
2991
|
+
if (!patchResult.ok) {
|
|
2992
|
+
console.log(`[SemanticDispatch] ⚠️ TemplateDiff failed: ${patchResult.reason}`);
|
|
2993
|
+
// Structural simplification fallback: AI proposed a selector that dropped
|
|
2994
|
+
// or rewrote a dynamic span entirely. Try to derive what each span
|
|
2995
|
+
// expression needs to evaluate to, then trace that span back to its
|
|
2996
|
+
// definition and apply the sub-heal there.
|
|
2997
|
+
if (patchResult.newSpanValues) {
|
|
2998
|
+
const templateNode = node;
|
|
2999
|
+
const oldSpanValues = TemplateDiffService_1.TemplateDiffService.extractNodeSpanValues(templateNode, oldSelector);
|
|
3000
|
+
const spans = templateNode.getTemplateSpans();
|
|
3001
|
+
if (oldSpanValues &&
|
|
3002
|
+
oldSpanValues.length === patchResult.newSpanValues.length &&
|
|
3003
|
+
spans.length === oldSpanValues.length) {
|
|
3004
|
+
console.log(`[SemanticDispatch] 🔄 Template structural fallback: tracing ${spans.length} span(s)`);
|
|
3005
|
+
for (let i = 0; i < spans.length; i++) {
|
|
3006
|
+
const spanExpr = spans[i].getExpression();
|
|
3007
|
+
const traced = this.traceSpanExpression(spanExpr, sourceFile, caller.line, oldSpanValues[i], patchResult.newSpanValues[i]);
|
|
3008
|
+
if (traced)
|
|
3009
|
+
return traced;
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
if (patchResult.reason.includes("structural") || patchResult.reason.includes("not found")) {
|
|
3014
|
+
this.advisoryBuffer.push({
|
|
3015
|
+
code: HealingAdvisory_1.AdvisoryCode.MANUAL_REVIEW_TEMPLATE_COMPLEX,
|
|
3016
|
+
message: `Template literal requires structural change — automated patch skipped.`,
|
|
3017
|
+
affectedFile: sourceFile.getFilePath(),
|
|
3018
|
+
affectedLine: node.getStartLineNumber() - 1,
|
|
3019
|
+
suggestedAction: "Manually update the template literal and its callers.",
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
return null;
|
|
3023
|
+
}
|
|
3024
|
+
const lineUpdated = node.getStartLineNumber() - 1;
|
|
3025
|
+
node.replaceWithText(patchResult.patchedTemplate);
|
|
3026
|
+
sourceFile.saveSync();
|
|
3027
|
+
if (!this.validateFileSyntax(sourceFile)) {
|
|
3028
|
+
return {
|
|
3029
|
+
success: false,
|
|
3030
|
+
reason: "post-mutation syntax validation failed — reverted",
|
|
3031
|
+
strategy: "semantic-template",
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
return {
|
|
3035
|
+
success: true,
|
|
3036
|
+
reason: `surgical template patch (segments ${patchResult.changedSegmentIndices.join(",")})`,
|
|
3037
|
+
strategy: "semantic-template",
|
|
3038
|
+
lineUpdated,
|
|
3039
|
+
healedLocatorString: newSelector,
|
|
3040
|
+
definitionSite: { file: sourceFile.getFilePath(), line: lineUpdated },
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
traceSpanExpression(spanExpr, sourceFile, callerLine, oldSpanVal, newSpanVal) {
|
|
3044
|
+
if (!ts_morph_1.Node.isIdentifier(spanExpr))
|
|
3045
|
+
return null;
|
|
3046
|
+
const symbol = spanExpr.getSymbol();
|
|
3047
|
+
if (!symbol)
|
|
3048
|
+
return null;
|
|
3049
|
+
for (const decl of symbol.getDeclarations()) {
|
|
3050
|
+
if (!ts_morph_1.Node.isVariableDeclaration(decl))
|
|
3051
|
+
continue;
|
|
3052
|
+
const init = decl.getInitializer();
|
|
3053
|
+
if (!init)
|
|
3054
|
+
continue;
|
|
3055
|
+
if (ts_morph_1.Node.isCallExpression(init)) {
|
|
3056
|
+
return this.handleCallExpressionArg(init, sourceFile, oldSpanVal, newSpanVal);
|
|
3057
|
+
}
|
|
3058
|
+
if (ts_morph_1.Node.isStringLiteral(init) || ts_morph_1.Node.isNoSubstitutionTemplateLiteral(init)) {
|
|
3059
|
+
const result = InitializerUpdater_1.InitializerUpdater.apply(init, sourceFile, newSpanVal);
|
|
3060
|
+
if (!result)
|
|
3061
|
+
return null;
|
|
3062
|
+
sourceFile.saveSync();
|
|
3063
|
+
return {
|
|
3064
|
+
success: true,
|
|
3065
|
+
reason: `span literal updated via trace`,
|
|
3066
|
+
strategy: "semantic-template",
|
|
3067
|
+
lineUpdated: result.lineUpdated,
|
|
3068
|
+
healedLocatorString: result.healedLocatorString,
|
|
3069
|
+
definitionSite: { file: sourceFile.getFilePath(), line: result.lineUpdated },
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
if (ts_morph_1.Node.isTemplateExpression(init)) {
|
|
3073
|
+
return this.handleTemplateLiteralArg(init, sourceFile, { filePath: sourceFile.getFilePath(), line: callerLine }, oldSpanVal, newSpanVal);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
return null;
|
|
3077
|
+
}
|
|
3078
|
+
handleCallExpressionArg(node, sourceFile, oldSelector, newSelector) {
|
|
3079
|
+
const fnAnalysis = DefinitionTracer_1.FunctionalReturnAnalyzer.analyze(node, sourceFile);
|
|
3080
|
+
if (fnAnalysis.kind === "complex") {
|
|
3081
|
+
this.advisoryBuffer.push({
|
|
3082
|
+
code: HealingAdvisory_1.AdvisoryCode.MANUAL_REVIEW_CONDITIONAL_RETURN,
|
|
3083
|
+
message: `Function has conditional/complex return — automated patch skipped. Reason: ${fnAnalysis.reason}`,
|
|
3084
|
+
affectedFile: sourceFile.getFilePath(),
|
|
3085
|
+
suggestedAction: "Manually update the function's return expression.",
|
|
3086
|
+
});
|
|
3087
|
+
return null;
|
|
3088
|
+
}
|
|
3089
|
+
if (fnAnalysis.kind === "literal") {
|
|
3090
|
+
const result = InitializerUpdater_1.InitializerUpdater.apply(fnAnalysis.literalNode, fnAnalysis.functionFile, newSelector);
|
|
3091
|
+
if (!result)
|
|
3092
|
+
return null;
|
|
3093
|
+
if (!this.validateFileSyntax(fnAnalysis.functionFile)) {
|
|
3094
|
+
return {
|
|
3095
|
+
success: false,
|
|
3096
|
+
reason: "post-mutation syntax validation failed — reverted",
|
|
3097
|
+
strategy: "semantic-call-expression",
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
3100
|
+
return {
|
|
3101
|
+
success: true,
|
|
3102
|
+
reason: `function return literal patched @ line ${fnAnalysis.functionLine}`,
|
|
3103
|
+
strategy: "semantic-call-expression",
|
|
3104
|
+
lineUpdated: result.lineUpdated,
|
|
3105
|
+
healedLocatorString: result.healedLocatorString,
|
|
3106
|
+
definitionSite: { file: fnAnalysis.functionFile.getFilePath(), line: result.lineUpdated },
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
if (fnAnalysis.kind === "template") {
|
|
3110
|
+
const patchResult = TemplateDiffService_1.TemplateDiffService.computeNodePatch(fnAnalysis.templateNode, oldSelector, newSelector);
|
|
3111
|
+
if (!patchResult.ok) {
|
|
3112
|
+
console.log(`[SemanticDispatch] ⚠️ TemplateDiff (call) failed: ${patchResult.reason}`);
|
|
3113
|
+
if (patchResult.reason.includes("structural")) {
|
|
3114
|
+
this.advisoryBuffer.push({
|
|
3115
|
+
code: HealingAdvisory_1.AdvisoryCode.MANUAL_REVIEW_TEMPLATE_COMPLEX,
|
|
3116
|
+
message: `Function template return requires structural change — automated patch skipped.`,
|
|
3117
|
+
affectedFile: fnAnalysis.functionFile.getFilePath(),
|
|
3118
|
+
affectedLine: fnAnalysis.functionLine - 1,
|
|
3119
|
+
suggestedAction: "Manually update the function's template literal.",
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
return null;
|
|
3123
|
+
}
|
|
3124
|
+
const lineUpdated = fnAnalysis.templateNode.getStartLineNumber() - 1;
|
|
3125
|
+
fnAnalysis.templateNode.replaceWithText(patchResult.patchedTemplate);
|
|
3126
|
+
fnAnalysis.functionFile.saveSync();
|
|
3127
|
+
if (!this.validateFileSyntax(fnAnalysis.functionFile)) {
|
|
3128
|
+
return {
|
|
3129
|
+
success: false,
|
|
3130
|
+
reason: "post-mutation syntax validation failed — reverted",
|
|
3131
|
+
strategy: "semantic-call-expression",
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
return {
|
|
3135
|
+
success: true,
|
|
3136
|
+
reason: `function return template patched @ line ${fnAnalysis.functionLine}`,
|
|
3137
|
+
strategy: "semantic-call-expression",
|
|
3138
|
+
lineUpdated,
|
|
3139
|
+
healedLocatorString: newSelector,
|
|
3140
|
+
definitionSite: { file: fnAnalysis.functionFile.getFilePath(), line: lineUpdated },
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
return null;
|
|
3144
|
+
}
|
|
3145
|
+
validateFileSyntax(sourceFile) {
|
|
3146
|
+
const ALLOWED_CODES = new Set([
|
|
3147
|
+
2304, 2307, 2339, 2345, 2554, 2571, 7006, 7016, 7031, 18004,
|
|
3148
|
+
]);
|
|
3149
|
+
const diags = sourceFile.getPreEmitDiagnostics();
|
|
3150
|
+
const syntaxErrors = diags.filter((d) => {
|
|
3151
|
+
if (d.getCategory() !== 1)
|
|
3152
|
+
return false;
|
|
3153
|
+
return !ALLOWED_CODES.has(d.getCode());
|
|
3154
|
+
});
|
|
3155
|
+
if (syntaxErrors.length > 0) {
|
|
3156
|
+
const msgs = syntaxErrors.map((d) => String(d.getMessageText())).join("; ");
|
|
3157
|
+
console.warn(`[SemanticDispatch] ⚠️ syntax errors after mutation: ${msgs} — reverting`);
|
|
3158
|
+
this.advisoryBuffer.push({
|
|
3159
|
+
code: HealingAdvisory_1.AdvisoryCode.POST_MUTATION_TYPE_ERROR,
|
|
3160
|
+
message: `Post-mutation type errors detected — mutation reverted. Errors: ${msgs}`,
|
|
3161
|
+
affectedFile: sourceFile.getFilePath(),
|
|
3162
|
+
suggestedAction: "Manually inspect and fix the type errors introduced by this heal.",
|
|
3163
|
+
});
|
|
3164
|
+
sourceFile.refreshFromFileSystemSync();
|
|
3165
|
+
return false;
|
|
3166
|
+
}
|
|
3167
|
+
return true;
|
|
3168
|
+
}
|
|
3169
|
+
broadcastAndReturn(result, oldSelector, newSelector, caller) {
|
|
3170
|
+
if (result.success) {
|
|
3171
|
+
HealingRegistry_1.HealingRegistry.getInstance().broadcast({ filePath: caller.filePath, line: caller.line }, oldSelector, newSelector);
|
|
3172
|
+
return { ...result, healedLocatorString: newSelector };
|
|
3173
|
+
}
|
|
3174
|
+
return result;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
exports.ASTSourceUpdater = ASTSourceUpdater;
|