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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/dist/config/ConfigLoader.d.ts +1 -0
  3. package/dist/config/ConfigLoader.d.ts.map +1 -1
  4. package/dist/config/ConfigLoader.js +10 -0
  5. package/dist/config/DryRunGuard.d.ts +14 -0
  6. package/dist/config/DryRunGuard.d.ts.map +1 -0
  7. package/dist/config/DryRunGuard.js +79 -0
  8. package/dist/config/SelaConfig.d.ts +15 -1
  9. package/dist/config/SelaConfig.d.ts.map +1 -1
  10. package/dist/config/SelaConfig.js +27 -1
  11. package/dist/engine/SelaEngine.d.ts +26 -6
  12. package/dist/engine/SelaEngine.d.ts.map +1 -1
  13. package/dist/engine/SelaEngine.js +329 -77
  14. package/dist/errors/SelaError.d.ts +133 -0
  15. package/dist/errors/SelaError.d.ts.map +1 -0
  16. package/dist/errors/SelaError.js +155 -0
  17. package/dist/fixtures/expectProxy.d.ts.map +1 -1
  18. package/dist/fixtures/expectProxy.js +7 -0
  19. package/dist/fixtures/index.d.ts +7 -1
  20. package/dist/fixtures/index.d.ts.map +1 -1
  21. package/dist/fixtures/index.js +15 -0
  22. package/dist/fixtures/proxyTag.d.ts +12 -0
  23. package/dist/fixtures/proxyTag.d.ts.map +1 -0
  24. package/dist/fixtures/proxyTag.js +45 -0
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +6 -1
  28. package/dist/reporter/SelaReporter.d.ts +110 -0
  29. package/dist/reporter/SelaReporter.d.ts.map +1 -0
  30. package/dist/reporter/SelaReporter.js +328 -0
  31. package/dist/services/ASTSourceUpdater.d.ts +19 -0
  32. package/dist/services/ASTSourceUpdater.d.ts.map +1 -1
  33. package/dist/services/ASTSourceUpdater.js +173 -29
  34. package/dist/services/HealReportService.d.ts +62 -3
  35. package/dist/services/HealReportService.d.ts.map +1 -1
  36. package/dist/services/HealReportService.js +184 -14
  37. package/dist/services/HealingCacheService.d.ts +135 -0
  38. package/dist/services/HealingCacheService.d.ts.map +1 -0
  39. package/dist/services/HealingCacheService.js +460 -0
  40. package/dist/services/InitializerUpdater.d.ts.map +1 -1
  41. package/dist/services/InitializerUpdater.js +20 -1
  42. package/dist/services/IntentAuditor.d.ts +18 -1
  43. package/dist/services/IntentAuditor.d.ts.map +1 -1
  44. package/dist/services/IntentAuditor.js +19 -6
  45. package/dist/services/InteractiveReview.d.ts +24 -0
  46. package/dist/services/InteractiveReview.d.ts.map +1 -0
  47. package/dist/services/InteractiveReview.js +218 -0
  48. package/dist/services/LLMService.d.ts +23 -2
  49. package/dist/services/LLMService.d.ts.map +1 -1
  50. package/dist/services/LLMService.js +48 -12
  51. package/dist/services/PRAutomationService.d.ts +23 -3
  52. package/dist/services/PRAutomationService.d.ts.map +1 -1
  53. package/dist/services/PRAutomationService.js +325 -79
  54. package/dist/services/PendingPromptLedger.d.ts +44 -0
  55. package/dist/services/PendingPromptLedger.d.ts.map +1 -0
  56. package/dist/services/PendingPromptLedger.js +180 -0
  57. package/dist/services/ReportGenerator.d.ts +70 -0
  58. package/dist/services/ReportGenerator.d.ts.map +1 -0
  59. package/dist/services/ReportGenerator.js +191 -0
  60. package/dist/services/SafetyGuard.d.ts +34 -2
  61. package/dist/services/SafetyGuard.d.ts.map +1 -1
  62. package/dist/services/SafetyGuard.js +65 -13
  63. package/dist/services/SourceUpdater.d.ts.map +1 -1
  64. package/dist/services/SourceUpdater.js +44 -13
  65. package/dist/services/WorkspaceSnapshotService.d.ts +71 -0
  66. package/dist/services/WorkspaceSnapshotService.d.ts.map +1 -0
  67. package/dist/services/WorkspaceSnapshotService.js +202 -0
  68. package/dist/utils/IsolatedDiff.d.ts +45 -0
  69. package/dist/utils/IsolatedDiff.d.ts.map +1 -0
  70. package/dist/utils/IsolatedDiff.js +379 -0
  71. 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
- console.log(`[Sela] 🔍 Step 3: Requesting AI fix from LLM...`);
166
- const aiFix = await this.llmService.getFix({
167
- targetIntent: intentDescription,
168
- failedSelector: fullSelector,
169
- previousState: lastKnownState || {},
170
- currentDom: neighborhoodDom +
171
- contextHint +
172
- anchorContextHint +
173
- contentChangeInstructions,
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: typeof aiFix.confidence === "number" ? aiFix.confidence : 0,
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 Error(`[SafetyGuard] ${safetyDecision.level}: ${safetyDecision.reason}`);
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}%, ${parts.join(", ")})`);
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
- // Old line: PRE-mutation content at the line that ended up being mutated.
312
- // If the mutation was cross-file, read the pre-image of THAT file by
313
- // recovering it from disk minus our own change (we don't track that —
314
- // fall back to current pre-image if same file, else empty string).
315
- const oldCodeLine = sameFileDef && preMutationLines.length > 0
316
- ? (preMutationLines[writtenLine - 1] ?? "")
317
- : this._safeReadLine(mutatedFilePath, writtenLine);
318
- // New line: POST-mutation content at the same line in the mutated file.
319
- const newCodeLine = this._safeReadLine(mutatedFilePath, writtenLine);
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
- aiConfidence: typeof aiFix.confidence === "number" ? aiFix.confidence : 0,
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
- const isSafetyBlock = typeof error?.message === "string" && error.message.includes("[SafetyGuard]");
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
- * Snapshot the entire file as a string[] of lines. Returns an empty array
465
- * if the file is missing or unreadable. Used to preserve the PRE-mutation
466
- * source so the diff in HealedEvent reflects the line that was actually
467
- * mutated (which may differ from the failure line when JumpToDef lands on
468
- * a const declaration above).
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
- _safeReadAllLines(filePath) {
627
+ _captureIsolatedDiff(filePath) {
471
628
  try {
472
629
  const cleaned = this._toReportFile(filePath);
473
- const abs = path.isAbsolute(cleaned) ? cleaned : path.resolve(process.cwd(), cleaned);
474
- if (!fs.existsSync(abs))
475
- return [];
476
- return fs.readFileSync(abs, "utf8").split(/\r?\n/);
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
- return [];
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: meta.auditorConfidence ?? 0,
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
- // ── PR Automation — batched git-flow at session end ─────────────
611
- if (this.config.prAutomation.enabled) {
612
- try {
613
- const branchInfo = (0, PRAutomationService_1.detectBranch)(process.cwd());
614
- const decision = (0, PRAutomationService_1.decideEffectiveStrategy)(this.config.prAutomation, branchInfo.branch, healedEvents);
615
- if (decision.downgradeReason) {
616
- console.warn(`[Sela PR] ⚠️ Strategy downgraded: '${decision.configured}' → '${decision.effective}' ` +
617
- `(reason: ${decision.downgradeReason}, ` +
618
- `minAI: ${decision.minAiConfidence}%, minAuditor: ${decision.minAuditorConfidence}%)`);
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
- else {
621
- console.log(`[Sela PR] 🎯 Effective strategy: '${decision.effective}' on branch '${branchInfo.branch ?? "unknown"}'`);
855
+ catch (err) {
856
+ console.warn(`[Sela PR] ⚠️ PR automation step failed: ${err.message}`);
622
857
  }
623
- const ctx = { cwd: process.cwd(), reportHtmlPath };
624
- await (0, PRAutomationService_1.execute)(this.config.prAutomation, decision, healedEvents, branchInfo, ctx);
625
- await (0, PRAutomationService_1.handleBugDetected)(this.config.prAutomation.onBugDetected, protectedEvents, branchInfo, ctx);
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 PR] ⚠️ PR automation step failed: ${err.message}`);
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)