sela-core 1.0.5 → 1.0.7
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 +1 -1
- package/dist/config/ConfigLoader.d.ts +1 -0
- package/dist/config/ConfigLoader.d.ts.map +1 -1
- package/dist/config/ConfigLoader.js +10 -0
- package/dist/config/DryRunGuard.d.ts +14 -0
- package/dist/config/DryRunGuard.d.ts.map +1 -0
- package/dist/config/DryRunGuard.js +79 -0
- package/dist/config/SelaConfig.d.ts +15 -1
- package/dist/config/SelaConfig.d.ts.map +1 -1
- package/dist/config/SelaConfig.js +27 -1
- package/dist/engine/SelaEngine.d.ts +26 -6
- package/dist/engine/SelaEngine.d.ts.map +1 -1
- package/dist/engine/SelaEngine.js +329 -77
- package/dist/errors/SelaError.d.ts +133 -0
- package/dist/errors/SelaError.d.ts.map +1 -0
- package/dist/errors/SelaError.js +155 -0
- package/dist/fixtures/expectProxy.d.ts.map +1 -1
- package/dist/fixtures/expectProxy.js +7 -0
- package/dist/fixtures/index.d.ts +7 -1
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/index.js +15 -0
- package/dist/fixtures/proxyTag.d.ts +12 -0
- package/dist/fixtures/proxyTag.d.ts.map +1 -0
- package/dist/fixtures/proxyTag.js +45 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/reporter/SelaReporter.d.ts +110 -0
- package/dist/reporter/SelaReporter.d.ts.map +1 -0
- package/dist/reporter/SelaReporter.js +328 -0
- package/dist/services/ASTSourceUpdater.d.ts +19 -0
- package/dist/services/ASTSourceUpdater.d.ts.map +1 -1
- package/dist/services/ASTSourceUpdater.js +173 -29
- package/dist/services/HealReportService.d.ts +62 -3
- package/dist/services/HealReportService.d.ts.map +1 -1
- package/dist/services/HealReportService.js +184 -14
- package/dist/services/HealingCacheService.d.ts +135 -0
- package/dist/services/HealingCacheService.d.ts.map +1 -0
- package/dist/services/HealingCacheService.js +460 -0
- package/dist/services/InitializerUpdater.d.ts.map +1 -1
- package/dist/services/InitializerUpdater.js +20 -1
- package/dist/services/IntentAuditor.d.ts +18 -1
- package/dist/services/IntentAuditor.d.ts.map +1 -1
- package/dist/services/IntentAuditor.js +19 -6
- package/dist/services/InteractiveReview.d.ts +24 -0
- package/dist/services/InteractiveReview.d.ts.map +1 -0
- package/dist/services/InteractiveReview.js +218 -0
- package/dist/services/LLMService.d.ts +23 -2
- package/dist/services/LLMService.d.ts.map +1 -1
- package/dist/services/LLMService.js +48 -12
- package/dist/services/PRAutomationService.d.ts +23 -3
- package/dist/services/PRAutomationService.d.ts.map +1 -1
- package/dist/services/PRAutomationService.js +325 -79
- package/dist/services/PendingPromptLedger.d.ts +44 -0
- package/dist/services/PendingPromptLedger.d.ts.map +1 -0
- package/dist/services/PendingPromptLedger.js +180 -0
- package/dist/services/ReportGenerator.d.ts +70 -0
- package/dist/services/ReportGenerator.d.ts.map +1 -0
- package/dist/services/ReportGenerator.js +191 -0
- package/dist/services/SafetyGuard.d.ts +34 -2
- package/dist/services/SafetyGuard.d.ts.map +1 -1
- package/dist/services/SafetyGuard.js +65 -13
- package/dist/services/SourceUpdater.d.ts.map +1 -1
- package/dist/services/SourceUpdater.js +44 -13
- package/dist/services/WorkspaceSnapshotService.d.ts +71 -0
- package/dist/services/WorkspaceSnapshotService.d.ts.map +1 -0
- package/dist/services/WorkspaceSnapshotService.js +202 -0
- package/dist/utils/IsolatedDiff.d.ts +45 -0
- package/dist/utils/IsolatedDiff.d.ts.map +1 -0
- package/dist/utils/IsolatedDiff.js +379 -0
- package/package.json +71 -67
|
@@ -37,17 +37,47 @@ exports.SelaEngine = void 0;
|
|
|
37
37
|
const fs = __importStar(require("fs"));
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const LLMService_1 = require("../services/LLMService");
|
|
40
|
+
const WorkspaceSnapshotService_1 = require("../services/WorkspaceSnapshotService");
|
|
41
|
+
const IsolatedDiff_1 = require("../utils/IsolatedDiff");
|
|
40
42
|
const SnapshotService_1 = require("../services/SnapshotService");
|
|
41
43
|
const DOMUtils_1 = require("../utils/DOMUtils");
|
|
42
44
|
const SourceUpdater_1 = require("../services/SourceUpdater");
|
|
45
|
+
const ASTSourceUpdater_1 = require("../services/ASTSourceUpdater");
|
|
43
46
|
const SafetyGuard_1 = require("../services/SafetyGuard");
|
|
47
|
+
const HealingCacheService_1 = require("../services/HealingCacheService");
|
|
44
48
|
const ConfigLoader_1 = require("../config/ConfigLoader");
|
|
49
|
+
const DryRunGuard_1 = require("../config/DryRunGuard");
|
|
45
50
|
const HealReportService_1 = require("../services/HealReportService");
|
|
46
51
|
const PRAutomationService_1 = require("../services/PRAutomationService");
|
|
52
|
+
const PendingPromptLedger_1 = require("../services/PendingPromptLedger");
|
|
53
|
+
const SelaError_1 = require("../errors/SelaError");
|
|
54
|
+
/**
|
|
55
|
+
* Graceful percentage formatter for engine-side log lines. Mirrors the helper
|
|
56
|
+
* in PRAutomationService / HealReportService so a missing AI/auditor
|
|
57
|
+
* confidence reads as "n/a" instead of "undefined%" or "0%".
|
|
58
|
+
*/
|
|
59
|
+
function fmtPctEngine(v) {
|
|
60
|
+
return typeof v === "number" && Number.isFinite(v) ? `${v}%` : "n/a";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Truthy when the current process is a CI runner. We accept the standard
|
|
64
|
+
* `CI=1`/`CI=true` convention used by GitHub Actions, GitLab, CircleCI,
|
|
65
|
+
* Buildkite, etc. Anything else (empty string, undefined, `0`, `false`)
|
|
66
|
+
* is treated as Local DX mode.
|
|
67
|
+
*/
|
|
68
|
+
function isCiEnvironment() {
|
|
69
|
+
const raw = process.env.CI;
|
|
70
|
+
if (raw === undefined || raw === null)
|
|
71
|
+
return false;
|
|
72
|
+
const v = String(raw).trim().toLowerCase();
|
|
73
|
+
return v !== "" && v !== "0" && v !== "false";
|
|
74
|
+
}
|
|
47
75
|
class SelaEngine {
|
|
48
76
|
llmService;
|
|
49
77
|
snapshotService;
|
|
50
78
|
safetyGuard;
|
|
79
|
+
astDirectiveScanner;
|
|
80
|
+
healingCache;
|
|
51
81
|
config;
|
|
52
82
|
dnaBuffer = new Map();
|
|
53
83
|
healMetaBuffer = new Map();
|
|
@@ -57,6 +87,14 @@ class SelaEngine {
|
|
|
57
87
|
this.llmService = new LLMService_1.LLMService();
|
|
58
88
|
this.snapshotService = new SnapshotService_1.SnapshotService();
|
|
59
89
|
this.safetyGuard = new SafetyGuard_1.SafetyGuard(this.config.thresholds);
|
|
90
|
+
// Dedicated lightweight ts-morph project used only for the leading-comment
|
|
91
|
+
// pre-scan. Keeping it separate from the SourceUpdater's project avoids
|
|
92
|
+
// polluting that cache with files we may never actually mutate.
|
|
93
|
+
this.astDirectiveScanner = new ASTSourceUpdater_1.ASTSourceUpdater();
|
|
94
|
+
// Local healing cache — short-circuits the LLM call when an identical
|
|
95
|
+
// (filePath, oldSelector) pair was healed safely in a prior session.
|
|
96
|
+
// Disabled when SELA_NO_CACHE=1; reads/writes skipped during dry runs.
|
|
97
|
+
this.healingCache = HealingCacheService_1.sharedHealingCache;
|
|
60
98
|
}
|
|
61
99
|
// ─────────────────────────────────────────────────────────────
|
|
62
100
|
// healArgument — repairs a wrong action argument (e.g. selectOption)
|
|
@@ -67,10 +105,10 @@ class SelaEngine {
|
|
|
67
105
|
const { dom } = await DOMUtils_1.DOMUtils.getNeighborhoodDom(page, selector);
|
|
68
106
|
const neighborhoodDom = dom;
|
|
69
107
|
const targetIntent = action === "selectOption"
|
|
70
|
-
? `ARGUMENT_HEALING: The selectOption argument "${oldArgument}" does not exist.
|
|
71
|
-
Look at the <select> element in the DOM and find all <option> elements inside it.
|
|
72
|
-
Return in 'new_selector' ONLY the TEXT CONTENT of the closest matching <option>.
|
|
73
|
-
For example: if the options are "Male" and "Female" and the old argument was "Man", return "Male".
|
|
108
|
+
? `ARGUMENT_HEALING: The selectOption argument "${oldArgument}" does not exist.
|
|
109
|
+
Look at the <select> element in the DOM and find all <option> elements inside it.
|
|
110
|
+
Return in 'new_selector' ONLY the TEXT CONTENT of the closest matching <option>.
|
|
111
|
+
For example: if the options are "Male" and "Female" and the old argument was "Man", return "Male".
|
|
74
112
|
CRITICAL: Do NOT return a CSS selector like "#my-select". Return plain text only, exactly as it appears in the option.`
|
|
75
113
|
: `Fix the broken argument "${oldArgument}" for action "${action}". Return the correct value.`;
|
|
76
114
|
const aiFix = await this.llmService.getFix({
|
|
@@ -99,6 +137,37 @@ CRITICAL: Do NOT return a CSS selector like "#my-select". Return plain text only
|
|
|
99
137
|
// Resolve file path for report purposes (project-relative when possible).
|
|
100
138
|
const reportFile = this._toReportFile(filePath);
|
|
101
139
|
const testTitle = this.testTitleBuffer.get(stableId);
|
|
140
|
+
// ── Developer fail-fast directive pre-gate ──────────────────────
|
|
141
|
+
// Scan the failing statement's leading comments for `// sela-fail-fast`
|
|
142
|
+
// BEFORE the expensive LLM round-trip. When present, record a
|
|
143
|
+
// "Skipped by Developer" FailedEvent and propagate the canonical
|
|
144
|
+
// SelaError(AST_HEAL_DISABLED_BY_DIRECTIVE) so retries are skipped too.
|
|
145
|
+
try {
|
|
146
|
+
this.astDirectiveScanner.enforceFailFastDirective({ filePath, line });
|
|
147
|
+
}
|
|
148
|
+
catch (directiveErr) {
|
|
149
|
+
if ((0, SelaError_1.isSelaErrorFrom)(directiveErr, "ASTUpdater") &&
|
|
150
|
+
directiveErr.code === SelaError_1.AST_UPDATER_CODES.HEAL_DISABLED_BY_DIRECTIVE) {
|
|
151
|
+
console.warn(`[Sela] 🛑 Skipped by Developer — ${directiveErr.reason}`);
|
|
152
|
+
const skipEvent = {
|
|
153
|
+
kind: "FAILED",
|
|
154
|
+
stableId,
|
|
155
|
+
sourceFile: reportFile,
|
|
156
|
+
sourceLine: line,
|
|
157
|
+
testTitle,
|
|
158
|
+
timestamp: new Date().toISOString(),
|
|
159
|
+
framePath: [],
|
|
160
|
+
inIframe: false,
|
|
161
|
+
inShadowDom: false,
|
|
162
|
+
oldSelector: elementSelectorOnly,
|
|
163
|
+
reason: "Skipped by Developer (sela-fail-fast directive)",
|
|
164
|
+
dnaBefore: null,
|
|
165
|
+
};
|
|
166
|
+
HealReportService_1.sharedHealReport.record(skipEvent);
|
|
167
|
+
throw directiveErr;
|
|
168
|
+
}
|
|
169
|
+
throw directiveErr;
|
|
170
|
+
}
|
|
102
171
|
// Snapshot for report event context (loaded once below, used at all exit paths).
|
|
103
172
|
let lastKnownStateForReport = null;
|
|
104
173
|
let aiFixForReport = null;
|
|
@@ -125,19 +194,19 @@ CRITICAL: Do NOT return a CSS selector like "#my-select". Return plain text only
|
|
|
125
194
|
const intentDescription = lastKnownState
|
|
126
195
|
? `The element that was a ${lastKnownState.tagName} with text "${lastKnownState.text}"`
|
|
127
196
|
: `Element identified by ${stableId}`;
|
|
128
|
-
const contentChangeInstructions = `
|
|
129
|
-
|
|
130
|
-
[CONTENT CHANGE DETECTION]:
|
|
131
|
-
If the element's VISIBLE TEXT has changed compared to its previous value${oldText ? ` ("${oldText}")` : ""}, you MUST include a "contentChange" field.
|
|
132
|
-
NOTE: This change will NOT be applied automatically to the source code to prevent masking potential bugs.
|
|
133
|
-
It will only be presented as a suggested fix in the terminal.
|
|
134
|
-
{
|
|
135
|
-
"contentChange": {
|
|
136
|
-
"oldText": "<the previous visible text>",
|
|
137
|
-
"newText": "<the new visible text as it appears in the current DOM>"
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
This is required so that any expect(...).toHaveText("${oldText ?? "..."}") assertions in the test file can be automatically updated to match the new text.
|
|
197
|
+
const contentChangeInstructions = `
|
|
198
|
+
|
|
199
|
+
[CONTENT CHANGE DETECTION]:
|
|
200
|
+
If the element's VISIBLE TEXT has changed compared to its previous value${oldText ? ` ("${oldText}")` : ""}, you MUST include a "contentChange" field.
|
|
201
|
+
NOTE: This change will NOT be applied automatically to the source code to prevent masking potential bugs.
|
|
202
|
+
It will only be presented as a suggested fix in the terminal.
|
|
203
|
+
{
|
|
204
|
+
"contentChange": {
|
|
205
|
+
"oldText": "<the previous visible text>",
|
|
206
|
+
"newText": "<the new visible text as it appears in the current DOM>"
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
This is required so that any expect(...).toHaveText("${oldText ?? "..."}") assertions in the test file can be automatically updated to match the new text.
|
|
141
210
|
If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
142
211
|
// ── Semantic anchor context hint for the LLM ─────────────────────
|
|
143
212
|
// When DNA v2 anchor data exists, tell the AI which label and row
|
|
@@ -162,16 +231,56 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
162
231
|
"\n" +
|
|
163
232
|
"Prioritize selectors that preserve these relationships (e.g. filter by row key, then target by label).");
|
|
164
233
|
})();
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
234
|
+
// ── Cache pre-check ────────────────────────────────────────────
|
|
235
|
+
// When a prior session healed this exact (filePath, oldSelector)
|
|
236
|
+
// pair AND SafetyGuard cleared it, replay the cached fix instead
|
|
237
|
+
// of paying the LLM round-trip. Disabled when SELA_NO_CACHE=1.
|
|
238
|
+
// The synthetic aiFix is fed through SafetyGuard exactly like a
|
|
239
|
+
// fresh LLM response — cache is a latency optimisation, never a
|
|
240
|
+
// safety bypass.
|
|
241
|
+
let aiFix = null;
|
|
242
|
+
let cacheHit = false;
|
|
243
|
+
// Fingerprint inputs the cache key depends on. Recomputed every
|
|
244
|
+
// heal so a DNA rewrite — which changes `dnaContentHash` —
|
|
245
|
+
// automatically invalidates every prior entry without an explicit
|
|
246
|
+
// purge.
|
|
247
|
+
const dnaContentHash = (0, HealingCacheService_1.hashDnaContent)(lastKnownState ?? null);
|
|
248
|
+
const cacheKeyInput = {
|
|
249
|
+
filePath,
|
|
250
|
+
line,
|
|
251
|
+
dnaContentHash,
|
|
252
|
+
brokenSelector: fullSelector,
|
|
253
|
+
mode: healMode,
|
|
254
|
+
};
|
|
255
|
+
if (!this.config.noCache) {
|
|
256
|
+
const cached = this.healingCache.get(cacheKeyInput);
|
|
257
|
+
if (cached) {
|
|
258
|
+
console.log(`[Sela] 🧊 Cache hit (0ms LLM) — replaying healed selector "${cached.healedSelector}"`);
|
|
259
|
+
aiFix = {
|
|
260
|
+
status: "FIXED",
|
|
261
|
+
new_selector: cached.healedSelector,
|
|
262
|
+
chainSegments: cached.chainSegments,
|
|
263
|
+
segments: cached.segments ??
|
|
264
|
+
[{ type: "element", selector: cached.healedSelector }],
|
|
265
|
+
confidence: cached.confidence,
|
|
266
|
+
contentChange: cached.contentChange,
|
|
267
|
+
explanation: cached.explanation ?? "Replayed from local healing cache",
|
|
268
|
+
};
|
|
269
|
+
cacheHit = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (!cacheHit) {
|
|
273
|
+
console.log(`[Sela] 🔍 Step 3: Requesting AI fix from LLM...`);
|
|
274
|
+
aiFix = await this.llmService.getFix({
|
|
275
|
+
targetIntent: intentDescription,
|
|
276
|
+
failedSelector: fullSelector,
|
|
277
|
+
previousState: lastKnownState || {},
|
|
278
|
+
currentDom: neighborhoodDom +
|
|
279
|
+
contextHint +
|
|
280
|
+
anchorContextHint +
|
|
281
|
+
contentChangeInstructions,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
175
284
|
aiFixForReport = aiFix;
|
|
176
285
|
if (aiFix && aiFix.status === "FIXED") {
|
|
177
286
|
console.log(`[Sela] 🔍 Step 3: AI responded ✅`);
|
|
@@ -262,14 +371,31 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
262
371
|
reason: safetyDecision.reason,
|
|
263
372
|
safetyLevel: safetyDecision.level,
|
|
264
373
|
auditor: this._auditorBlockFromDecision(safetyDecision.meta),
|
|
265
|
-
aiConfidence:
|
|
374
|
+
aiConfidence: aiFix.confidence,
|
|
266
375
|
aiExplanation: aiFix.explanation ?? "",
|
|
267
376
|
dnaBefore: (0, HealReportService_1.summariseSnapshot)(lastKnownState),
|
|
268
377
|
dnaAfter: (0, HealReportService_1.summariseSnapshot)(liveSnapshot ?? null),
|
|
269
378
|
contentChange: aiFix.contentChange,
|
|
270
379
|
};
|
|
271
380
|
HealReportService_1.sharedHealReport.record(protectedEvent);
|
|
272
|
-
throw new
|
|
381
|
+
throw new SelaError_1.SelaError({
|
|
382
|
+
subsystem: "SafetyGuard",
|
|
383
|
+
code: safetyDecision.code ?? SelaError_1.SAFETY_GUARD_CODES.GENERIC_REJECTION,
|
|
384
|
+
reason: safetyDecision.reason,
|
|
385
|
+
context: {
|
|
386
|
+
filePath: reportFile,
|
|
387
|
+
line,
|
|
388
|
+
selector: elementSelectorOnly,
|
|
389
|
+
healedSelector: newFullSelector,
|
|
390
|
+
stableId,
|
|
391
|
+
testTitle,
|
|
392
|
+
branch,
|
|
393
|
+
safetyLevel: safetyDecision.level,
|
|
394
|
+
auditorVerdict: safetyDecision.meta?.auditorVerdict,
|
|
395
|
+
auditorConfidence: safetyDecision.meta?.auditorConfidence,
|
|
396
|
+
aiConfidence: aiFix.confidence,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
273
399
|
}
|
|
274
400
|
// ── Heal-completion score summary ─────────────────────────────────
|
|
275
401
|
// Emits a single consolidated line showing AI confidence plus the
|
|
@@ -281,16 +407,11 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
281
407
|
parts.push(`${breakdown.penalty} ancestry drift`);
|
|
282
408
|
if (breakdown.bonus !== 0)
|
|
283
409
|
parts.push(`+${breakdown.bonus} anchor match`);
|
|
284
|
-
console.log(`[Sela] 📊 Score — AI: ${aiFix.confidence}
|
|
285
|
-
`Auditor: ${breakdown.adjustedConfidence}
|
|
286
|
-
`(raw ${breakdown.rawConfidence}
|
|
410
|
+
console.log(`[Sela] 📊 Score — AI: ${fmtPctEngine(aiFix.confidence)} | ` +
|
|
411
|
+
`Auditor: ${fmtPctEngine(breakdown.adjustedConfidence)} ` +
|
|
412
|
+
`(raw ${fmtPctEngine(breakdown.rawConfidence)}, ${parts.join(", ")})`);
|
|
287
413
|
}
|
|
288
414
|
console.log(`[Sela] 🚀 Rebuilt Full Runtime Selector (Clean): ${newFullSelector}`);
|
|
289
|
-
// Snapshot the ENTIRE source file BEFORE the AST mutation.
|
|
290
|
-
// We don't know which line will actually be mutated until update()
|
|
291
|
-
// returns (JumpToDef may land on the const-decl line, not the failure
|
|
292
|
-
// line) — so we keep the pre-image around and slice into it after.
|
|
293
|
-
const preMutationLines = this._safeReadAllLines(filePath);
|
|
294
415
|
const updateResult = SourceUpdater_1.SourceUpdater.update({ filePath, line }, elementSelectorOnly, newFullSelector, neighborhoodDom, undefined, fullSelector, aiFix.segments, aiFix.contentChange, aiFix.chainSegments, aiFix.originalChainHint, this.config.updateStrategy, this.config.autoCommit);
|
|
295
416
|
// Resolve the line that was actually mutated. Order of precedence:
|
|
296
417
|
// 1. updateResult.lineUpdated (the strategy's literal write location)
|
|
@@ -308,15 +429,21 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
308
429
|
(sameFileDef ? defSite?.line : undefined) ??
|
|
309
430
|
line;
|
|
310
431
|
const mutatedFilePath = !sameFileDef && defSite ? defSite.file : filePath;
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
432
|
+
// Clean-Room isolated diff. We diff the in-memory snapshot recorded
|
|
433
|
+
// by WorkspaceSnapshotService (contentBefore — disk state right
|
|
434
|
+
// before Sela's first write to this file) against the post-write
|
|
435
|
+
// disk state. The result contains EXCLUSIVELY Sela's selector swap;
|
|
436
|
+
// any developer dirty edits sit identically in both buffers and
|
|
437
|
+
// therefore cancel out. Falls back to "" when the snapshot is
|
|
438
|
+
// missing (cross-file healing into an untracked path, etc.).
|
|
439
|
+
const gitUnifiedDiff = this._captureIsolatedDiff(mutatedFilePath);
|
|
440
|
+
const firstChange = this._extractFirstChangeFromDiff(gitUnifiedDiff);
|
|
441
|
+
// Backward-compat: keep oldCodeLine/newCodeLine populated from the
|
|
442
|
+
// first -/+ pair inside the diff. When git produced no diff (file
|
|
443
|
+
// untracked, git unavailable, …) fall back to a direct disk read of
|
|
444
|
+
// the mutated line — strictly inferior but preserves old reports.
|
|
445
|
+
const oldCodeLine = firstChange.oldLine ?? this._safeReadLine(mutatedFilePath, writtenLine);
|
|
446
|
+
const newCodeLine = firstChange.newLine ?? this._safeReadLine(mutatedFilePath, writtenLine);
|
|
320
447
|
const canonicalSelector = updateResult.healedLocatorString ?? newFullSelector;
|
|
321
448
|
console.log(`[Sela] ✅ Canonical selector (disk == runtime): "${canonicalSelector}"`);
|
|
322
449
|
// Buffer CLI-ready metadata — merged into DNA at commitUpdates() time.
|
|
@@ -359,12 +486,14 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
359
486
|
framePath: successfulPath,
|
|
360
487
|
inIframe: successfulPath.length > 0,
|
|
361
488
|
inShadowDom: liveSnapshot?.isInShadowDom ?? false,
|
|
489
|
+
dryRun: DryRunGuard_1.DryRunGuard.active() ? true : undefined,
|
|
362
490
|
oldSelector: elementSelectorOnly,
|
|
363
491
|
newSelector: canonicalSelector,
|
|
364
492
|
oldCodeLine,
|
|
365
493
|
newCodeLine,
|
|
366
494
|
newLineNumber: writtenLine,
|
|
367
|
-
|
|
495
|
+
gitUnifiedDiff: gitUnifiedDiff || undefined,
|
|
496
|
+
aiConfidence: aiFix.confidence,
|
|
368
497
|
aiExplanation: aiFix.explanation ?? "",
|
|
369
498
|
aiAlternatives: this._buildAlternatives(aiFix.chainSegments, aiFix.segments),
|
|
370
499
|
reasoningSteps: this._buildReasoningSteps({
|
|
@@ -386,6 +515,25 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
386
515
|
contentChange: aiFix.contentChange,
|
|
387
516
|
};
|
|
388
517
|
HealReportService_1.sharedHealReport.record(healedEvent);
|
|
518
|
+
// ── Cache write — persist this safe heal for future sessions ──
|
|
519
|
+
// Best-effort: failures are logged inside the service and never
|
|
520
|
+
// block the heal return. Skipped automatically during dry runs
|
|
521
|
+
// and when SELA_NO_CACHE=1.
|
|
522
|
+
if (!this.config.noCache && !cacheHit) {
|
|
523
|
+
this.healingCache
|
|
524
|
+
.set({
|
|
525
|
+
...cacheKeyInput,
|
|
526
|
+
healedSelector: canonicalSelector,
|
|
527
|
+
chainSegments: aiFix.chainSegments,
|
|
528
|
+
segments: aiFix.segments,
|
|
529
|
+
confidence: aiFix.confidence,
|
|
530
|
+
contentChange: aiFix.contentChange,
|
|
531
|
+
explanation: aiFix.explanation,
|
|
532
|
+
alternatives: this._buildAlternatives(aiFix.chainSegments, aiFix.segments),
|
|
533
|
+
safe: true,
|
|
534
|
+
})
|
|
535
|
+
.catch((err) => console.warn(`[HealingCache] ⚠️ set() failed: ${err?.message ?? err}`));
|
|
536
|
+
}
|
|
389
537
|
return canonicalSelector;
|
|
390
538
|
}
|
|
391
539
|
// AI did not return FIXED — record as FAILED.
|
|
@@ -411,7 +559,11 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
411
559
|
catch (error) {
|
|
412
560
|
console.error(`[Sela] ❌ Healing Failure:`, error.message);
|
|
413
561
|
// Avoid double-recording when a SafetyGuard PROTECTED event was already pushed.
|
|
414
|
-
|
|
562
|
+
// Prefer the structured instanceof check; fall back to message-substring
|
|
563
|
+
// detection for any legacy throw sites that have not yet been migrated.
|
|
564
|
+
const isSafetyBlock = (0, SelaError_1.isSelaErrorFrom)(error, "SafetyGuard") ||
|
|
565
|
+
(typeof error?.message === "string" && error.message.includes("[Sela-SafetyGuard]")) ||
|
|
566
|
+
(typeof error?.message === "string" && error.message.includes("[SafetyGuard]"));
|
|
415
567
|
const isNotFoundAlreadyRecorded = typeof error?.message === "string" && error.message === "AI could not provide a fix";
|
|
416
568
|
if (!isSafetyBlock && !isNotFoundAlreadyRecorded) {
|
|
417
569
|
const failedEvent = {
|
|
@@ -461,30 +613,95 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
461
613
|
}
|
|
462
614
|
}
|
|
463
615
|
/**
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
616
|
+
* Clean-Room isolated diff: render a unified diff between the file's
|
|
617
|
+
* pre-Sela disk snapshot (`contentBefore`) and the post-write disk state
|
|
618
|
+
* (`contentAfter`) captured by `WorkspaceSnapshotService`. Returns "" when
|
|
619
|
+
* the file was never tracked (no snapshot) or the AST mutation produced
|
|
620
|
+
* no net change.
|
|
621
|
+
*
|
|
622
|
+
* This is what makes the HTML report immune to developer dirty edits: any
|
|
623
|
+
* pre-existing uncommitted changes live in BOTH buffers identically and
|
|
624
|
+
* are therefore filtered out by the diff. The output contains only Sela's
|
|
625
|
+
* selector swap.
|
|
469
626
|
*/
|
|
470
|
-
|
|
627
|
+
_captureIsolatedDiff(filePath) {
|
|
471
628
|
try {
|
|
472
629
|
const cleaned = this._toReportFile(filePath);
|
|
473
|
-
const abs = path.isAbsolute(cleaned)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
630
|
+
const abs = path.isAbsolute(cleaned)
|
|
631
|
+
? cleaned
|
|
632
|
+
: path.resolve(process.cwd(), cleaned);
|
|
633
|
+
if (!abs)
|
|
634
|
+
return "";
|
|
635
|
+
const snap = WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.getSnapshot(abs);
|
|
636
|
+
if (!snap || snap.contentAfter === null)
|
|
637
|
+
return "";
|
|
638
|
+
if (snap.contentBefore === snap.contentAfter)
|
|
639
|
+
return "";
|
|
640
|
+
return (0, IsolatedDiff_1.generateUnifiedDiff)(snap.contentBefore, snap.contentAfter, {
|
|
641
|
+
filePath: snap.relativePath,
|
|
642
|
+
context: 1,
|
|
643
|
+
});
|
|
477
644
|
}
|
|
478
|
-
catch {
|
|
479
|
-
|
|
645
|
+
catch (err) {
|
|
646
|
+
console.debug?.(`[Sela] isolated diff capture skipped: ${err?.message ?? err}`);
|
|
647
|
+
return "";
|
|
480
648
|
}
|
|
481
649
|
}
|
|
650
|
+
/**
|
|
651
|
+
* Walk a unified diff and pull out the first removed line and the first
|
|
652
|
+
* added line so the legacy report fields (`oldCodeLine`, `newCodeLine`)
|
|
653
|
+
* stay populated for callers that haven't migrated to the full
|
|
654
|
+
* `gitUnifiedDiff` payload yet.
|
|
655
|
+
*
|
|
656
|
+
* Carefully skips the diff preamble (--- a/file, +++ b/file, "diff ",
|
|
657
|
+
* "index ", rename headers) — those lines would otherwise be mistaken for
|
|
658
|
+
* a -/+ change pair and produce nonsense like the original "line replaced
|
|
659
|
+
* by itself" output. Real change lines only count once the parser has seen
|
|
660
|
+
* a "@@" hunk header.
|
|
661
|
+
*/
|
|
662
|
+
_extractFirstChangeFromDiff(diff) {
|
|
663
|
+
if (!diff)
|
|
664
|
+
return {};
|
|
665
|
+
let inHunk = false;
|
|
666
|
+
let oldLine;
|
|
667
|
+
let newLine;
|
|
668
|
+
for (const row of diff.split(/\r?\n/)) {
|
|
669
|
+
if (!inHunk) {
|
|
670
|
+
if (row.startsWith("@@"))
|
|
671
|
+
inHunk = true;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
// Defensive: a fresh @@ header starts a new hunk but does not reset
|
|
675
|
+
// the values we've already captured.
|
|
676
|
+
if (row.startsWith("@@"))
|
|
677
|
+
continue;
|
|
678
|
+
if (row.startsWith("---") || row.startsWith("+++"))
|
|
679
|
+
continue;
|
|
680
|
+
if (row.startsWith("\\"))
|
|
681
|
+
continue;
|
|
682
|
+
if (row.startsWith("-") && oldLine === undefined) {
|
|
683
|
+
oldLine = row.slice(1);
|
|
684
|
+
}
|
|
685
|
+
else if (row.startsWith("+") && newLine === undefined) {
|
|
686
|
+
newLine = row.slice(1);
|
|
687
|
+
}
|
|
688
|
+
if (oldLine !== undefined && newLine !== undefined)
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
return { oldLine, newLine };
|
|
692
|
+
}
|
|
482
693
|
_auditorBlockFromDecision(meta) {
|
|
483
694
|
if (!meta || !meta.auditorVerdict)
|
|
484
695
|
return null;
|
|
696
|
+
// Keep auditor confidence as `undefined` when SafetyGuard did not produce
|
|
697
|
+
// one — the renderer fmtPct helper turns it into "n/a" rather than 0%.
|
|
698
|
+
const conf = typeof meta.auditorConfidence === "number" &&
|
|
699
|
+
Number.isFinite(meta.auditorConfidence)
|
|
700
|
+
? meta.auditorConfidence
|
|
701
|
+
: undefined;
|
|
485
702
|
return {
|
|
486
703
|
verdict: meta.auditorVerdict,
|
|
487
|
-
confidence:
|
|
704
|
+
confidence: conf,
|
|
488
705
|
reason: meta.auditScoreBreakdown
|
|
489
706
|
? `Adjusted ${meta.auditScoreBreakdown.rawConfidence}% → ${meta.auditScoreBreakdown.adjustedConfidence}% (penalty ${meta.auditScoreBreakdown.penalty}, bonus +${meta.auditScoreBreakdown.bonus}).`
|
|
490
707
|
: "Auditor verdict recorded by SafetyGuard.",
|
|
@@ -562,7 +779,7 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
562
779
|
if (ctx.aiFix.explanation) {
|
|
563
780
|
steps.push(`AI reasoning: ${ctx.aiFix.explanation}`);
|
|
564
781
|
}
|
|
565
|
-
steps.push(`Confidence: ${ctx.aiFix.confidence}
|
|
782
|
+
steps.push(`Confidence: ${fmtPctEngine(ctx.aiFix.confidence)}.`);
|
|
566
783
|
if (ctx.aiFix.contentChange) {
|
|
567
784
|
steps.push(`Detected content drift: "${ctx.aiFix.contentChange.oldText}" → "${ctx.aiFix.contentChange.newText}".`);
|
|
568
785
|
}
|
|
@@ -607,25 +824,54 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
607
824
|
console.warn(`[Sela] ⚠️ Failed to write insights report: ${err.message}`);
|
|
608
825
|
}
|
|
609
826
|
}
|
|
610
|
-
// ──
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
827
|
+
// ── Dual-Mode post-run lifecycle ─────────────────────────────────
|
|
828
|
+
//
|
|
829
|
+
// CI (process.env.CI truthy) → Clean-Room PR automation (existing flow).
|
|
830
|
+
// Local DX (CI falsy) → Interactive CLI review of each mutated
|
|
831
|
+
// file; Apply keeps the on-disk fix, Reject
|
|
832
|
+
// rewinds via WorkspaceSnapshotService.
|
|
833
|
+
//
|
|
834
|
+
// The interactive branch deliberately bypasses PR automation because:
|
|
835
|
+
// • AST fixes are ALREADY persisted to disk (the test passed on them).
|
|
836
|
+
// • Local devs want to inspect + cherry-pick, not auto-commit.
|
|
837
|
+
if (isCiEnvironment()) {
|
|
838
|
+
if (this.config.prAutomation.enabled) {
|
|
839
|
+
try {
|
|
840
|
+
const branchInfo = (0, PRAutomationService_1.detectBranch)(process.cwd());
|
|
841
|
+
const decision = (0, PRAutomationService_1.decideEffectiveStrategy)(this.config.prAutomation, branchInfo.branch, healedEvents);
|
|
842
|
+
if (decision.downgradeReason) {
|
|
843
|
+
console.warn(`[Sela PR] ⚠️ Strategy downgraded: '${decision.configured}' → '${decision.effective}' ` +
|
|
844
|
+
`(reason: ${decision.downgradeReason}, ` +
|
|
845
|
+
`minAI: ${fmtPctEngine(decision.minAiConfidence)}, ` +
|
|
846
|
+
`minAuditor: ${fmtPctEngine(decision.minAuditorConfidence)})`);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
console.log(`[Sela PR] 🎯 Effective strategy: '${decision.effective}' on branch '${branchInfo.branch ?? "unknown"}'`);
|
|
850
|
+
}
|
|
851
|
+
const ctx = { cwd: process.cwd(), reportHtmlPath };
|
|
852
|
+
await (0, PRAutomationService_1.execute)(this.config.prAutomation, decision, healedEvents, branchInfo, ctx);
|
|
853
|
+
await (0, PRAutomationService_1.handleBugDetected)(this.config.prAutomation.onBugDetected, protectedEvents, branchInfo, ctx);
|
|
619
854
|
}
|
|
620
|
-
|
|
621
|
-
console.
|
|
855
|
+
catch (err) {
|
|
856
|
+
console.warn(`[Sela PR] ⚠️ PR automation step failed: ${err.message}`);
|
|
622
857
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
else if (healedEvents.length > 0) {
|
|
861
|
+
// Local DX: do NOT block this process on stdin — workers have no
|
|
862
|
+
// TTY and would deadlock until Playwright's per-test timeout fires.
|
|
863
|
+
// Serialise the mutated snapshots to a cross-process ledger; the
|
|
864
|
+
// SelaReporter drains it from `onEnd()` in the main process where
|
|
865
|
+
// an interactive `readline` is safe.
|
|
866
|
+
try {
|
|
867
|
+
(0, PendingPromptLedger_1.writePendingPromptsLedger)({
|
|
868
|
+
cwd: process.cwd(),
|
|
869
|
+
workspace: WorkspaceSnapshotService_1.sharedWorkspaceSnapshot,
|
|
870
|
+
healedEvents,
|
|
871
|
+
});
|
|
626
872
|
}
|
|
627
873
|
catch (err) {
|
|
628
|
-
console.warn(`[Sela
|
|
874
|
+
console.warn(`[Sela] ⚠️ Pending-prompts ledger write failed: ${err.message}`);
|
|
629
875
|
}
|
|
630
876
|
}
|
|
631
877
|
// Clear the report buffer last so any downstream consumers (e.g. test
|
|
@@ -633,6 +879,12 @@ If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
|
633
879
|
if (HealReportService_1.sharedHealReport.hasContent()) {
|
|
634
880
|
HealReportService_1.sharedHealReport.clear();
|
|
635
881
|
}
|
|
882
|
+
// Drop the Clean-Room workspace snapshot ledger so the next session
|
|
883
|
+
// starts with an empty `contentBefore` map. Without this, a long-lived
|
|
884
|
+
// Node process (`vitest --watch`, IDE test runner) would carry stale
|
|
885
|
+
// pre-Sela snapshots from one session into the next, causing the
|
|
886
|
+
// isolated-diff layer to compare against the wrong baseline.
|
|
887
|
+
WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.clear();
|
|
636
888
|
}
|
|
637
889
|
// ─────────────────────────────────────────────────────────────
|
|
638
890
|
// fetchLiveText — frame-aware innerText fetch (zero-trust probe)
|