sela-core 1.0.6 → 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 +12 -9
  12. package/dist/engine/SelaEngine.d.ts.map +1 -1
  13. package/dist/engine/SelaEngine.js +249 -72
  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 +42 -0
  35. package/dist/services/HealReportService.d.ts.map +1 -1
  36. package/dist/services/HealReportService.js +80 -1
  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 +17 -1
  49. package/dist/services/LLMService.d.ts.map +1 -1
  50. package/dist/services/LLMService.js +18 -8
  51. package/dist/services/PRAutomationService.d.ts +14 -1
  52. package/dist/services/PRAutomationService.d.ts.map +1 -1
  53. package/dist/services/PRAutomationService.js +260 -69
  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 +27 -1
  61. package/dist/services/SafetyGuard.d.ts.map +1 -1
  62. package/dist/services/SafetyGuard.js +58 -12
  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,6 +37,9 @@ exports.ASTSourceUpdater = void 0;
37
37
  const path = __importStar(require("path"));
38
38
  const fs = __importStar(require("fs"));
39
39
  const ts_morph_1 = require("ts-morph");
40
+ const DryRunGuard_1 = require("../config/DryRunGuard");
41
+ const WorkspaceSnapshotService_1 = require("./WorkspaceSnapshotService");
42
+ const SelaError_1 = require("../errors/SelaError");
40
43
  const HealingRegistry_1 = require("../engine/HealingRegistry");
41
44
  const ArgumentTypeAnalyzer_1 = require("./ArgumentTypeAnalyzer");
42
45
  const BlastRadiusAnalyzer_1 = require("./BlastRadiusAnalyzer");
@@ -723,6 +726,33 @@ function resolveVariableToSegments(sourceFile, varName, beforeLine, depth = 0) {
723
726
  };
724
727
  }
725
728
  // ═══════════════════════════════════════════════════════════════════
729
+ // SHARED PERSISTENCE HOOK
730
+ //
731
+ // Module-level write gate shared between `ASTSourceUpdater` and the
732
+ // internal `NodeTransformationStrategy`. Centralises three concerns
733
+ // every `saveSync` call site needs:
734
+ // 1. `DryRunGuard.active()` short-circuit — keeps the mutation in
735
+ // memory but suppresses the on-disk write under SELA_DRY_RUN.
736
+ // 2. `sharedWorkspaceSnapshot.preWrite()` — captures `contentBefore`
737
+ // so the Clean-Room ledger / Local DX rollback can rewind.
738
+ // 3. `sharedWorkspaceSnapshot.postWrite()` — refreshes `contentAfter`
739
+ // so `getMutatedSnapshots()` (driving the pending-prompts ledger
740
+ // and the interactive CLI prompt) sees the new disk state.
741
+ //
742
+ // Strategies do NOT receive the persistence service via DI — they call
743
+ // this helper directly. Same singleton, zero coupling, one code path.
744
+ // ═══════════════════════════════════════════════════════════════════
745
+ function persistSourceFile(sourceFile) {
746
+ if (DryRunGuard_1.DryRunGuard.active()) {
747
+ DryRunGuard_1.DryRunGuard.logSkippedWrite("saveSync", sourceFile.getFilePath());
748
+ return;
749
+ }
750
+ const fp = sourceFile.getFilePath();
751
+ WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.preWrite(fp);
752
+ sourceFile.saveSync();
753
+ WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.postWrite(fp);
754
+ }
755
+ // ═══════════════════════════════════════════════════════════════════
726
756
  // NODE TRANSFORMATION STRATEGY — Scope-Aware, Zero-Redundancy
727
757
  // ═══════════════════════════════════════════════════════════════════
728
758
  class NodeTransformationStrategy {
@@ -959,7 +989,7 @@ class NodeTransformationStrategy {
959
989
  ` ${newCode.split("\n")[0]}`);
960
990
  try {
961
991
  chainTop.replaceWithText(newCode);
962
- sourceFile.saveSync();
992
+ persistSourceFile(sourceFile);
963
993
  return {
964
994
  success: true,
965
995
  reason: `scope-aware transform [${effectiveReceiver}]: ${resolvedSegments.length} segments`,
@@ -1146,7 +1176,7 @@ class NodeTransformationStrategy {
1146
1176
  console.log(`[NodeTransform] 🔬 Partial: segment[${changedIdx}] .${currentMethodName}() → ${newCallText}`);
1147
1177
  try {
1148
1178
  targetCall.replaceWithText(newCallText);
1149
- sourceFile.saveSync();
1179
+ persistSourceFile(sourceFile);
1150
1180
  return {
1151
1181
  success: true,
1152
1182
  reason: `partial: seg[${changedIdx}] .${currentMethodName}() → .${newSeg.type}()`,
@@ -1201,7 +1231,7 @@ class NodeTransformationStrategy {
1201
1231
  if (!ts_morph_1.Node.isStringLiteral(firstArg))
1202
1232
  continue;
1203
1233
  firstArg.replaceWithText(`"${newSelector}"`);
1204
- sourceFile.saveSync();
1234
+ persistSourceFile(sourceFile);
1205
1235
  return true;
1206
1236
  }
1207
1237
  return false;
@@ -1529,6 +1559,112 @@ class ASTSourceUpdater {
1529
1559
  flushAdvisories() {
1530
1560
  this.advisoryBuffer.flush();
1531
1561
  }
1562
+ /**
1563
+ * Centralised write hook for every ts-morph `saveSync` call in this class.
1564
+ * When `SELA_DRY_RUN` is active the AST mutation stays in memory but the
1565
+ * on-disk file is left untouched — the heal pipeline can still compute a
1566
+ * candidate diff via `sourceFile.getFullText()` and the report layer
1567
+ * tags the event with `dryRun: true`.
1568
+ */
1569
+ _persistChanges(sourceFile) {
1570
+ persistSourceFile(sourceFile);
1571
+ }
1572
+ // ─────────────────────────────────────────────────────────────────
1573
+ // FAIL-FAST DIRECTIVE SCAN
1574
+ //
1575
+ // Walks the leading comment ranges of the failing statement (and the
1576
+ // CallExpression node when it differs) looking for the directive
1577
+ // `// sela-fail-fast`. When found, throws `SelaError(ASTUpdater,
1578
+ // AST_HEAL_DISABLED_BY_DIRECTIVE)` so SelaEngine can short-circuit
1579
+ // BEFORE the LLM call and record a FailedEvent in the "Skipped by
1580
+ // Developer" category.
1581
+ //
1582
+ // Implementation note — ts-morph's `Node.getLeadingCommentRanges()`
1583
+ // returns `ts.CommentRange[]` (offsets, not text). We slice the full
1584
+ // source text by `(pos, end)` to recover the raw comment for each
1585
+ // range, matching both `//` line comments and `/* */` block comments
1586
+ // with either `sela-fail-fast` or `@sela-fail-fast` spelling.
1587
+ // ─────────────────────────────────────────────────────────────────
1588
+ scanFailFastDirective(caller) {
1589
+ const filePath = this.resolveFilePath(caller.filePath);
1590
+ if (!filePath)
1591
+ return { failFast: false };
1592
+ let sourceFile = this.project.getSourceFile(filePath);
1593
+ if (sourceFile) {
1594
+ sourceFile.refreshFromFileSystemSync();
1595
+ }
1596
+ else {
1597
+ sourceFile = this.project.addSourceFileAtPath(filePath);
1598
+ }
1599
+ if (caller.line <= 0)
1600
+ return { failFast: false };
1601
+ const failureNode = this.findNodeAtLine(sourceFile, caller.line);
1602
+ if (!failureNode)
1603
+ return { failFast: false };
1604
+ const statement = this.getEnclosingStatement(failureNode);
1605
+ // Collect candidate comment-owning nodes: enclosing statement (catches
1606
+ // pre-statement comments) AND the failure node itself (catches inline
1607
+ // pre-CallExpression comments inside multi-line chains). De-dup by
1608
+ // offset to avoid double-matching.
1609
+ const fullText = sourceFile.getFullText();
1610
+ const seen = new Set();
1611
+ const nodes = [];
1612
+ if (statement)
1613
+ nodes.push(statement);
1614
+ if (failureNode !== statement)
1615
+ nodes.push(failureNode);
1616
+ const DIRECTIVE_RE = /\/[\/*]\s*@?sela-fail-fast\b/i;
1617
+ for (const node of nodes) {
1618
+ let ranges = [];
1619
+ try {
1620
+ ranges = node.getLeadingCommentRanges() ?? [];
1621
+ }
1622
+ catch {
1623
+ ranges = [];
1624
+ }
1625
+ for (const r of ranges) {
1626
+ const pos = r.getPos();
1627
+ const end = r.getEnd();
1628
+ const key = `${pos}:${end}`;
1629
+ if (seen.has(key))
1630
+ continue;
1631
+ seen.add(key);
1632
+ const text = fullText.slice(pos, end);
1633
+ if (DIRECTIVE_RE.test(text)) {
1634
+ const lineNum = sourceFile.getLineAndColumnAtPos(pos).line;
1635
+ return {
1636
+ failFast: true,
1637
+ matchedComment: text.trim(),
1638
+ matchedLine: lineNum,
1639
+ };
1640
+ }
1641
+ }
1642
+ }
1643
+ return { failFast: false };
1644
+ }
1645
+ /**
1646
+ * Convenience helper — runs `scanFailFastDirective` and throws the
1647
+ * canonical SelaError when the directive is present. Used by SelaEngine
1648
+ * as the very first gate inside `heal()`.
1649
+ */
1650
+ enforceFailFastDirective(caller) {
1651
+ const result = this.scanFailFastDirective(caller);
1652
+ if (!result.failFast)
1653
+ return;
1654
+ throw new SelaError_1.SelaError({
1655
+ subsystem: "ASTUpdater",
1656
+ code: SelaError_1.AST_UPDATER_CODES.HEAL_DISABLED_BY_DIRECTIVE,
1657
+ reason: `Heal disabled by developer directive at ${path.basename(caller.filePath)}` +
1658
+ (result.matchedLine ? `:${result.matchedLine}` : ""),
1659
+ context: {
1660
+ filePath: caller.filePath,
1661
+ line: caller.line,
1662
+ directiveLine: result.matchedLine,
1663
+ directiveText: result.matchedComment,
1664
+ category: "Skipped by Developer",
1665
+ },
1666
+ });
1667
+ }
1532
1668
  constructor() {
1533
1669
  this.project = new ts_morph_1.Project({
1534
1670
  useInMemoryFileSystem: false,
@@ -1673,6 +1809,14 @@ class ASTSourceUpdater {
1673
1809
  const statement = failureNode
1674
1810
  ? this.getEnclosingStatement(failureNode)
1675
1811
  : undefined;
1812
+ // ─── Developer fail-fast directive ─────────────────────────────
1813
+ // Scan leading comments on the enclosing statement (and the failure
1814
+ // node when they differ) for `// sela-fail-fast`. When present, abort
1815
+ // all heal work with SelaError(AST_HEAL_DISABLED_BY_DIRECTIVE) so the
1816
+ // engine records a FailedEvent in the "Skipped by Developer" bucket.
1817
+ // Defense-in-depth — SelaEngine also calls this at the very start of
1818
+ // heal() to skip the LLM round-trip entirely.
1819
+ this.enforceFailFastDirective(caller);
1676
1820
  const suspectIdentifier = statement
1677
1821
  ? this.identifySuspectIdentifier(statement, caller.line)
1678
1822
  : null;
@@ -1850,7 +1994,7 @@ class ASTSourceUpdater {
1850
1994
  console.log(`[ASTUpdater] 🔗 H: full replace (depth=${chainDepth}) →\n${newCode}`);
1851
1995
  try {
1852
1996
  chainTop.replaceWithText(newCode);
1853
- sourceFile.saveSync();
1997
+ this._persistChanges(sourceFile);
1854
1998
  return {
1855
1999
  success: true,
1856
2000
  reason: `smart chain applied (${resolvedSegments.length} segments, depth=${chainDepth})`,
@@ -1914,7 +2058,7 @@ class ASTSourceUpdater {
1914
2058
  console.log(`[ASTUpdater] 🔗 H partial: .${currentMethodName}() → ${newCallText}`);
1915
2059
  try {
1916
2060
  targetCall.replaceWithText(newCallText);
1917
- sourceFile.saveSync();
2061
+ this._persistChanges(sourceFile);
1918
2062
  return {
1919
2063
  success: true,
1920
2064
  reason: `partial chain heal: segment[${changedIdx}] .${currentMethodName}() → .${newSeg.type}()`,
@@ -1975,7 +2119,7 @@ class ASTSourceUpdater {
1975
2119
  const newCallText = buildCallText(rootReceiver, newSem);
1976
2120
  try {
1977
2121
  chainTop.replaceWithText(newCallText);
1978
- sourceFile.saveSync();
2122
+ this._persistChanges(sourceFile);
1979
2123
  return {
1980
2124
  success: true,
1981
2125
  reason: `chain collapsed atomically (depth=${chainDepth})`,
@@ -2059,7 +2203,7 @@ class ASTSourceUpdater {
2059
2203
  }
2060
2204
  }
2061
2205
  if (anyFixed) {
2062
- sourceFile.saveSync();
2206
+ this._persistChanges(sourceFile);
2063
2207
  return {
2064
2208
  success: true,
2065
2209
  reason: `upstream variable '${varName}' fixed`,
@@ -2070,7 +2214,7 @@ class ASTSourceUpdater {
2070
2214
  for (const change of changedFrames) {
2071
2215
  const result = this.replaceStringLiteralInRange(sourceFile, change.old, change.new, declLine, Math.min(sourceFile.getEndLineNumber(), declLine + 8));
2072
2216
  if (result !== null) {
2073
- sourceFile.saveSync();
2217
+ this._persistChanges(sourceFile);
2074
2218
  return {
2075
2219
  success: true,
2076
2220
  reason: "multi-line frame fixed",
@@ -2085,7 +2229,7 @@ class ASTSourceUpdater {
2085
2229
  for (const change of changedFrames) {
2086
2230
  const result = this.replaceStringLiteralInRange(sourceFile, change.old, change.new, Math.max(1, caller.line - 15), caller.line);
2087
2231
  if (result !== null) {
2088
- sourceFile.saveSync();
2232
+ this._persistChanges(sourceFile);
2089
2233
  return {
2090
2234
  success: true,
2091
2235
  reason: "inline frame fixed",
@@ -2166,10 +2310,10 @@ class ASTSourceUpdater {
2166
2310
  const newCallText = buildCallText(effectiveReceiver, newSem);
2167
2311
  try {
2168
2312
  chainTop.replaceWithText(newCallText);
2169
- sourceFile.saveSync();
2313
+ this._persistChanges(sourceFile);
2170
2314
  const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
2171
2315
  if (cascadePairs.length > 0) {
2172
- sourceFile.saveSync();
2316
+ this._persistChanges(sourceFile);
2173
2317
  HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
2174
2318
  }
2175
2319
  return {
@@ -2208,10 +2352,10 @@ class ASTSourceUpdater {
2208
2352
  const { chainTop: tailChainTop, rootReceiver } = climbToChainTop(tailCall);
2209
2353
  try {
2210
2354
  tailChainTop.replaceWithText(buildCallText(rootReceiver, newSem));
2211
- sourceFile.saveSync();
2355
+ this._persistChanges(sourceFile);
2212
2356
  const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
2213
2357
  if (cascadePairs.length > 0) {
2214
- sourceFile.saveSync();
2358
+ this._persistChanges(sourceFile);
2215
2359
  HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
2216
2360
  }
2217
2361
  return {
@@ -2228,10 +2372,10 @@ class ASTSourceUpdater {
2228
2372
  }
2229
2373
  const fixed = this.replaceChainTailOnly(tailCall, newSem, newSelector);
2230
2374
  if (fixed) {
2231
- sourceFile.saveSync();
2375
+ this._persistChanges(sourceFile);
2232
2376
  const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
2233
2377
  if (cascadePairs.length > 0) {
2234
- sourceFile.saveSync();
2378
+ this._persistChanges(sourceFile);
2235
2379
  HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
2236
2380
  }
2237
2381
  return {
@@ -2253,7 +2397,7 @@ class ASTSourceUpdater {
2253
2397
  if (chainDepth > 0 || isLeafOfChain(call)) {
2254
2398
  try {
2255
2399
  stmtChainTop.replaceWithText(buildCallText(rootReceiver, newSem));
2256
- sourceFile.saveSync();
2400
+ this._persistChanges(sourceFile);
2257
2401
  return {
2258
2402
  success: true,
2259
2403
  reason: "direct statement chain collapse",
@@ -2264,10 +2408,10 @@ class ASTSourceUpdater {
2264
2408
  catch { }
2265
2409
  }
2266
2410
  if (this.replaceChainTailOnly(call, newSem, newSelector)) {
2267
- sourceFile.saveSync();
2411
+ this._persistChanges(sourceFile);
2268
2412
  const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
2269
2413
  if (cascadePairs.length > 0) {
2270
- sourceFile.saveSync();
2414
+ this._persistChanges(sourceFile);
2271
2415
  HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
2272
2416
  }
2273
2417
  return {
@@ -2301,10 +2445,10 @@ class ASTSourceUpdater {
2301
2445
  try {
2302
2446
  chainTop.replaceWithText(buildCallText(rootReceiver, newSem));
2303
2447
  const fixLine = chainTop.getStartLineNumber();
2304
- sourceFile.saveSync();
2448
+ this._persistChanges(sourceFile);
2305
2449
  const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
2306
2450
  if (cascadePairs.length > 0) {
2307
- sourceFile.saveSync();
2451
+ this._persistChanges(sourceFile);
2308
2452
  HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
2309
2453
  }
2310
2454
  return {
@@ -2319,10 +2463,10 @@ class ASTSourceUpdater {
2319
2463
  }
2320
2464
  if (this.applySemanticFix(call, newSem, newSelector)) {
2321
2465
  const fixLine = call.getStartLineNumber();
2322
- sourceFile.saveSync();
2466
+ this._persistChanges(sourceFile);
2323
2467
  const cascadePairs = this.performCascadeHeal(sourceFile, oldSelector, newSelector, oldSem, newSem);
2324
2468
  if (cascadePairs.length > 0) {
2325
- sourceFile.saveSync();
2469
+ this._persistChanges(sourceFile);
2326
2470
  HealingRegistry_1.HealingRegistry.getInstance().broadcastCascade({ filePath: caller.filePath, line: caller.line }, cascadePairs);
2327
2471
  }
2328
2472
  return {
@@ -2385,7 +2529,7 @@ class ASTSourceUpdater {
2385
2529
  if (newText !== fullText) {
2386
2530
  tmpl.replaceWithText(newText.trim());
2387
2531
  const line = tmpl.getStartLineNumber();
2388
- sourceFile.saveSync();
2532
+ this._persistChanges(sourceFile);
2389
2533
  return {
2390
2534
  success: true,
2391
2535
  reason: "template literal suffix updated",
@@ -2430,7 +2574,7 @@ class ASTSourceUpdater {
2430
2574
  if (chainDepth > 0 || isLeafOfChain(call)) {
2431
2575
  try {
2432
2576
  chainTop.replaceWithText(buildCallText(rootReceiver, newSem));
2433
- sourceFile.saveSync();
2577
+ this._persistChanges(sourceFile);
2434
2578
  return {
2435
2579
  success: true,
2436
2580
  reason: `fragment scan chain collapse .${callSem.method}() @ line ${call.getStartLineNumber()}`,
@@ -2441,7 +2585,7 @@ class ASTSourceUpdater {
2441
2585
  catch { }
2442
2586
  }
2443
2587
  if (this.replaceChainTailOnly(call, newSem, newSelector)) {
2444
- sourceFile.saveSync();
2588
+ this._persistChanges(sourceFile);
2445
2589
  return {
2446
2590
  success: true,
2447
2591
  reason: `fragment scan fixed .${callSem.method}() @ line ${call.getStartLineNumber()}`,
@@ -2618,7 +2762,7 @@ class ASTSourceUpdater {
2618
2762
  const propName = firstArg.getName();
2619
2763
  const result = this.updateObjectProperty(sourceFile, objName, propName, oldSem.primaryArg, newSem.primaryArg);
2620
2764
  if (result !== null) {
2621
- sourceFile.saveSync();
2765
+ this._persistChanges(sourceFile);
2622
2766
  return {
2623
2767
  success: true,
2624
2768
  reason: `'${objName}.${propName}' updated`,
@@ -3032,7 +3176,7 @@ class ASTSourceUpdater {
3032
3176
  }
3033
3177
  const lineUpdated = node.getStartLineNumber() - 1;
3034
3178
  node.replaceWithText(patchResult.patchedTemplate);
3035
- sourceFile.saveSync();
3179
+ this._persistChanges(sourceFile);
3036
3180
  if (!this.validateFileSyntax(sourceFile)) {
3037
3181
  return {
3038
3182
  success: false,
@@ -3068,7 +3212,7 @@ class ASTSourceUpdater {
3068
3212
  const result = InitializerUpdater_1.InitializerUpdater.apply(init, sourceFile, newSpanVal);
3069
3213
  if (!result)
3070
3214
  return null;
3071
- sourceFile.saveSync();
3215
+ this._persistChanges(sourceFile);
3072
3216
  return {
3073
3217
  success: true,
3074
3218
  reason: `span literal updated via trace`,
@@ -3132,7 +3276,7 @@ class ASTSourceUpdater {
3132
3276
  }
3133
3277
  const lineUpdated = fnAnalysis.templateNode.getStartLineNumber() - 1;
3134
3278
  fnAnalysis.templateNode.replaceWithText(patchResult.patchedTemplate);
3135
- fnAnalysis.functionFile.saveSync();
3279
+ this._persistChanges(fnAnalysis.functionFile);
3136
3280
  if (!this.validateFileSyntax(fnAnalysis.functionFile)) {
3137
3281
  return {
3138
3282
  success: false,
@@ -41,6 +41,13 @@ interface HealEventBase {
41
41
  }
42
42
  export interface HealedEvent extends HealEventBase {
43
43
  kind: "HEALED";
44
+ /**
45
+ * Marks events produced during a `SELA_DRY_RUN=1` session — the in-memory
46
+ * heal completed (LLM call, SafetyGuard verdict, candidate diff captured)
47
+ * but no source file was modified and no commit was produced. Reports
48
+ * render these with an explicit "Dry Run · No Write" chip.
49
+ */
50
+ dryRun?: boolean;
44
51
  oldSelector: string;
45
52
  newSelector: string;
46
53
  /** First removed line from the native git diff (backward-compat fallback). */
@@ -83,6 +90,16 @@ export interface FailedEvent extends HealEventBase {
83
90
  aiConfidence?: number;
84
91
  aiExplanation?: string;
85
92
  dnaBefore: DnaSummary | null;
93
+ /**
94
+ * Base64-encoded PNG screenshot captured by `SelaReporter` from the
95
+ * Playwright `TestResult.attachments` slot at the moment the test
96
+ * failed. NEVER prefixed with `data:image/png;base64,` — the HTML
97
+ * renderer prepends the data URL header at render time so the JSON
98
+ * payload stays compact and the bytes survive Playwright's temp
99
+ * folder cleanup. `undefined` when no screenshot was attached
100
+ * (worker crash, screenshot disabled, off-attachment failure).
101
+ */
102
+ failureScreenshotPng?: string;
86
103
  }
87
104
  export interface ProtectedEvent extends HealEventBase {
88
105
  kind: "PROTECTED";
@@ -114,6 +131,31 @@ export declare class HealReportService {
114
131
  getHealedEvents(): HealedEvent[];
115
132
  /** All PROTECTED events (SafetyGuard blocks) accumulated in this session. */
116
133
  getProtectedEvents(): ProtectedEvent[];
134
+ /** All FAILED events accumulated in this session. */
135
+ getFailedEvents(): FailedEvent[];
136
+ /**
137
+ * Inject a Playwright failure screenshot (Base64 PNG, no data: prefix)
138
+ * onto the FAILED event(s) matching the supplied identity hints. Used
139
+ * by `SelaReporter` to enrich engine-emitted FAILED events with the
140
+ * Playwright-captured screenshot from `TestResult.attachments`.
141
+ *
142
+ * Match policy (all supplied hints must agree):
143
+ * • `sourceFile` — suffix-matched both directions to absorb
144
+ * project-relative vs absolute path mismatches between the engine
145
+ * (relative) and Playwright TestInfo.location.file (absolute).
146
+ * • `testTitle` — exact match.
147
+ * • `stableId` — exact match.
148
+ *
149
+ * Returns the number of events updated. Zero means the caller should
150
+ * keep the screenshot pending until the matching FailedEvent is
151
+ * recorded (worker-crash edge case where the heal pipeline did not
152
+ * run before the Reporter received the failure).
153
+ */
154
+ attachFailureScreenshot(match: {
155
+ sourceFile?: string;
156
+ testTitle?: string;
157
+ stableId?: string;
158
+ }, base64Png: string): number;
117
159
  /**
118
160
  * Write `sela-report.html` (+ `sela-report.json` debug dump) to cwd.
119
161
  * Returns the absolute path of the HTML report, or null if nothing to write.
@@ -1 +1 @@
1
- {"version":3,"file":"HealReportService.d.ts","sourceRoot":"","sources":["../../src/services/HealReportService.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"HealReportService.d.ts","sourceRoot":"","sources":["../../src/services/HealReportService.ts"],"names":[],"mappings":"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"}
@@ -96,6 +96,52 @@ class HealReportService {
96
96
  getProtectedEvents() {
97
97
  return this.events.filter((e) => e.kind === "PROTECTED");
98
98
  }
99
+ /** All FAILED events accumulated in this session. */
100
+ getFailedEvents() {
101
+ return this.events.filter((e) => e.kind === "FAILED");
102
+ }
103
+ /**
104
+ * Inject a Playwright failure screenshot (Base64 PNG, no data: prefix)
105
+ * onto the FAILED event(s) matching the supplied identity hints. Used
106
+ * by `SelaReporter` to enrich engine-emitted FAILED events with the
107
+ * Playwright-captured screenshot from `TestResult.attachments`.
108
+ *
109
+ * Match policy (all supplied hints must agree):
110
+ * • `sourceFile` — suffix-matched both directions to absorb
111
+ * project-relative vs absolute path mismatches between the engine
112
+ * (relative) and Playwright TestInfo.location.file (absolute).
113
+ * • `testTitle` — exact match.
114
+ * • `stableId` — exact match.
115
+ *
116
+ * Returns the number of events updated. Zero means the caller should
117
+ * keep the screenshot pending until the matching FailedEvent is
118
+ * recorded (worker-crash edge case where the heal pipeline did not
119
+ * run before the Reporter received the failure).
120
+ */
121
+ attachFailureScreenshot(match, base64Png) {
122
+ if (!base64Png)
123
+ return 0;
124
+ const norm = (p) => (p ?? "").replace(/\\/g, "/").toLowerCase();
125
+ const targetFile = norm(match.sourceFile);
126
+ let touched = 0;
127
+ for (const ev of this.events) {
128
+ if (ev.kind !== "FAILED")
129
+ continue;
130
+ const evFile = norm(ev.sourceFile);
131
+ const fileMatch = !targetFile ||
132
+ !evFile ||
133
+ evFile === targetFile ||
134
+ evFile.endsWith(targetFile) ||
135
+ targetFile.endsWith(evFile);
136
+ const titleMatch = !match.testTitle || ev.testTitle === match.testTitle;
137
+ const idMatch = !match.stableId || ev.stableId === match.stableId;
138
+ if (fileMatch && titleMatch && idMatch) {
139
+ ev.failureScreenshotPng = base64Png;
140
+ touched++;
141
+ }
142
+ }
143
+ return touched;
144
+ }
99
145
  /**
100
146
  * Write `sela-report.html` (+ `sela-report.json` debug dump) to cwd.
101
147
  * Returns the absolute path of the HTML report, or null if nothing to write.
@@ -684,6 +730,23 @@ a:hover { text-decoration: underline; }
684
730
  .tag.ghost { color: var(--warn); border-color: rgba(251,191,36,0.3); }
685
731
  .tag.occlude { color: var(--bad); border-color: rgba(248,113,113,0.3); }
686
732
 
733
+ /* Failure context — Playwright screenshot rendered inline on FAILED cards */
734
+ .failure-img {
735
+ background: var(--bg-1); border: 1px solid var(--line);
736
+ border-radius: var(--radius-sm); padding: 8px;
737
+ overflow: hidden;
738
+ }
739
+ .failure-img img {
740
+ display: block; max-width: 100%; height: auto;
741
+ border-radius: 4px;
742
+ background: #000;
743
+ outline: 1px solid var(--line);
744
+ }
745
+ .failure-img .img-meta {
746
+ font-family: var(--mono); font-size: 11px; color: var(--text-muted);
747
+ padding: 6px 4px 0;
748
+ }
749
+
687
750
  /* Empty state */
688
751
  .empty {
689
752
  margin-top: 32px; padding: 60px 20px; text-align: center;
@@ -1061,6 +1124,19 @@ const REPORT_JS = String.raw `
1061
1124
  );
1062
1125
  }
1063
1126
 
1127
+ function renderFailureContext(ev) {
1128
+ if (ev.kind !== "FAILED" || !ev.failureScreenshotPng) return "";
1129
+ return (
1130
+ '<div class="section">' +
1131
+ '<div class="section-title"><span class="marker"></span>Failure Context · Playwright Screenshot</div>' +
1132
+ '<div class="failure-img">' +
1133
+ '<img src="data:image/png;base64,' + ev.failureScreenshotPng + '" alt="Playwright failure screenshot" loading="lazy" />' +
1134
+ '<div class="img-meta">captured by SelaReporter at test failure · base64-embedded · survives Playwright temp cleanup</div>' +
1135
+ '</div>' +
1136
+ '</div>'
1137
+ );
1138
+ }
1139
+
1064
1140
  function renderAlts(ev) {
1065
1141
  if (ev.kind !== "HEALED" || !ev.aiAlternatives || !ev.aiAlternatives.length) return "";
1066
1142
  return (
@@ -1150,7 +1226,10 @@ const REPORT_JS = String.raw `
1150
1226
  '</div>'
1151
1227
  );
1152
1228
 
1153
- // 5. Alternatives
1229
+ // 5. Failure Context — Playwright screenshot (FAILED only)
1230
+ blocks.push(renderFailureContext(ev));
1231
+
1232
+ // 6. Alternatives
1154
1233
  blocks.push(renderAlts(ev));
1155
1234
 
1156
1235
  // 6. Actions