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
@@ -3,6 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.SafetyGuard = void 0;
5
5
  const IntentAuditor_1 = require("./IntentAuditor");
6
+ const SelaError_1 = require("../errors/SelaError");
6
7
  // ═══════════════════════════════════════════════════════════════════
7
8
  // DEFAULT THRESHOLDS (match pre-config hardcoded values exactly)
8
9
  // ═══════════════════════════════════════════════════════════════════
@@ -55,6 +56,9 @@ const SEMANTIC_OPPOSITE_PAIRS = new Set([
55
56
  "valid|invalid",
56
57
  "invalid|valid",
57
58
  ]);
59
+ function escapeRegExp(s) {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }
58
62
  const ROLE_KEYWORDS = new Map([
59
63
  [
60
64
  "SUBMIT",
@@ -185,21 +189,47 @@ const ROLE_KEYWORDS = new Map([
185
189
  ]),
186
190
  ],
187
191
  ]);
188
- // ═══════════════════════════════════════════════════════════════════
189
- // SafetyGuard
190
- // ═══════════════════════════════════════════════════════════════════
192
+ // Pre-compiled word-boundary regexes for the role classifier. Built
193
+ // once at module load — classifyFunctionalRole() is on the hot heal
194
+ // path, so we don't pay for regex construction per call.
195
+ const ROLE_KEYWORD_REGEXES = Array.from(ROLE_KEYWORDS, ([role, keywords]) => ({
196
+ role,
197
+ regexes: Array.from(keywords, (kw) => new RegExp(`\\b${escapeRegExp(kw)}\\b`, "i")),
198
+ }));
191
199
  class SafetyGuard {
192
200
  intentAuditor;
193
201
  thresholds;
194
- constructor(thresholds) {
195
- this.thresholds = thresholds ?? DEFAULT_THRESHOLDS;
196
- this.intentAuditor = new IntentAuditor_1.IntentAuditor();
202
+ /**
203
+ * Accepts either the legacy positional `thresholds` argument or an
204
+ * options bag (DI-friendly). Existing call sites keep working
205
+ * unchanged.
206
+ */
207
+ constructor(arg) {
208
+ const opts = SafetyGuard.normalizeArgs(arg);
209
+ this.thresholds = opts.thresholds ?? DEFAULT_THRESHOLDS;
210
+ this.intentAuditor = opts.intentAuditor ?? new IntentAuditor_1.IntentAuditor();
211
+ }
212
+ static normalizeArgs(arg) {
213
+ if (!arg)
214
+ return {};
215
+ // Heuristic: an options bag has either `intentAuditor` or a nested
216
+ // `thresholds` field; otherwise the value itself is a ResolvedThresholds.
217
+ if ("intentAuditor" in arg || "thresholds" in arg) {
218
+ return arg;
219
+ }
220
+ return { thresholds: arg };
197
221
  }
198
222
  // ─────────────────────────────────────────────────────────────
199
223
  // PUBLIC ENTRY POINT
200
224
  // ─────────────────────────────────────────────────────────────
201
225
  async evaluate(context, aiFix) {
202
- const { confidence, liveText, dnaBaselineText } = aiFix;
226
+ const { liveText, dnaBaselineText } = aiFix;
227
+ // Conservative fallback for missing confidence: treat as 0 so the
228
+ // hardStop / reviewThreshold rules still gate the heal. Reporting layers
229
+ // separately render the absence as "n/a" via fmtPct.
230
+ const confidence = typeof aiFix.confidence === "number" && Number.isFinite(aiFix.confidence)
231
+ ? aiFix.confidence
232
+ : 0;
203
233
  const { mode } = context;
204
234
  // ── 0. Visibility Gate ───────────────────────────────────────
205
235
  // Runs before any confidence or content checks. A ghost element
@@ -211,6 +241,7 @@ class SafetyGuard {
211
241
  if (vis.isGhost) {
212
242
  return {
213
243
  level: "FAIL_HARD",
244
+ code: SelaError_1.SAFETY_GUARD_CODES.FAIL_HARD_VISIBILITY_GHOST,
214
245
  reason: `Visibility Gate: candidate element is a Ghost (${vis.ghostReason}) — ` +
215
246
  `functionally absent from the user's perspective`,
216
247
  canProceed: false,
@@ -221,6 +252,7 @@ class SafetyGuard {
221
252
  if (isMainBranch) {
222
253
  return {
223
254
  level: "FAIL_HARD",
255
+ code: SelaError_1.SAFETY_GUARD_CODES.FAIL_HARD_VISIBILITY_OCCLUDED,
224
256
  reason: `Visibility Gate: candidate element is occluded by an overlay on branch "${context.branch}" — ` +
225
257
  `hard-fail policy active on main`,
226
258
  canProceed: false,
@@ -240,6 +272,7 @@ class SafetyGuard {
240
272
  if (confidence < this.thresholds.confidenceHardStop) {
241
273
  return {
242
274
  level: "BLOCKED",
275
+ code: SelaError_1.SAFETY_GUARD_CODES.BLOCKED_LOW_CONFIDENCE,
243
276
  reason: `Confidence too low (${confidence}/100 < ${this.thresholds.confidenceHardStop}) — refusing to heal`,
244
277
  canProceed: false,
245
278
  };
@@ -284,6 +317,7 @@ class SafetyGuard {
284
317
  if (this.isSemanticOpposite(oldText, newText)) {
285
318
  return {
286
319
  level: "FAIL_HARD",
320
+ code: SelaError_1.SAFETY_GUARD_CODES.FAIL_HARD_SEMANTIC_INVERSION,
287
321
  reason: `Semantic inversion detected: "${oldText}" ↔ "${newText}" — ` +
288
322
  `state inversions mask logical regressions`,
289
323
  canProceed: false,
@@ -299,6 +333,7 @@ class SafetyGuard {
299
333
  if (isStatusInversion) {
300
334
  return {
301
335
  level: "FAIL_HARD",
336
+ code: SelaError_1.SAFETY_GUARD_CODES.FAIL_HARD_STATUS_INVERSION,
302
337
  reason: `Status inversion: "${oldText}" (${oldRole}) → "${newText}" (${newRole}) — ` +
303
338
  `masking a logic failure is not permitted`,
304
339
  canProceed: false,
@@ -313,6 +348,7 @@ class SafetyGuard {
313
348
  // function being found is almost certainly a wrong element fix.
314
349
  return {
315
350
  level: "BLOCKED",
351
+ code: SelaError_1.SAFETY_GUARD_CODES.BLOCKED_ROLE_SWAP_ASSERTION,
316
352
  reason: `Functional role swap in assertion: "${oldText}" (${oldRole}) → "${newText}" (${newRole}) — ` +
317
353
  `the assertion was verifying a specific functional state`,
318
354
  canProceed: false,
@@ -355,6 +391,7 @@ class SafetyGuard {
355
391
  if (auditVerdict.verdict === "INCONSISTENT") {
356
392
  return {
357
393
  level: "FAIL_HARD",
394
+ code: SelaError_1.SAFETY_GUARD_CODES.FAIL_HARD_AUDITOR_INCONSISTENT,
358
395
  reason: `Intent Auditor — INCONSISTENT: ${auditVerdict.reason}` +
359
396
  (auditVerdict.inversionType
360
397
  ? ` [inversion: ${auditVerdict.inversionType}]`
@@ -371,6 +408,7 @@ class SafetyGuard {
371
408
  if (!this.thresholds.allowSuspicious && mode === "assertion") {
372
409
  return {
373
410
  level: "BLOCKED",
411
+ code: SelaError_1.SAFETY_GUARD_CODES.BLOCKED_AUDITOR_SUSPICIOUS,
374
412
  reason: `Intent Auditor — SUSPICIOUS in assertion mode: ${auditVerdict.reason}`,
375
413
  canProceed: false,
376
414
  meta,
@@ -415,6 +453,7 @@ class SafetyGuard {
415
453
  if (similarity < this.thresholds.assertionSimilarityFloor) {
416
454
  return {
417
455
  level: "BLOCKED",
456
+ code: SelaError_1.SAFETY_GUARD_CODES.BLOCKED_SIMILARITY_FLOOR,
418
457
  reason: `Text divergence too high in assertion: "${oldText}" → "${newText}" ` +
419
458
  `(similarity=${(similarity * 100).toFixed(1)}% < ${(this.thresholds.assertionSimilarityFloor * 100).toFixed(0)}%) — ` +
420
459
  `possible content regression`,
@@ -449,16 +488,29 @@ class SafetyGuard {
449
488
  // ─────────────────────────────────────────────────────────────
450
489
  // FUNCTIONAL ROLE CLASSIFICATION
451
490
  //
452
- // Returns the first role whose keyword set contains the text
453
- // (exact match or substring). Returns UNKNOWN when no role matches.
491
+ // Returns the first role whose keyword matches the text as a whole
492
+ // word (delimited by \b boundaries). Returns UNKNOWN when no role
493
+ // matches.
494
+ //
495
+ // Why \b instead of `.includes(kw)`:
496
+ // - "unauthorized".includes("authorize") → true ← bug
497
+ // - "offline".includes("on") → true ← bug
498
+ // - "disconnected".includes("connected") → true ← bug
499
+ // - "inactive".includes("active") → true ← bug
500
+ // The substring approach mis-classified negations into the
501
+ // positive role of their root word, silently bypassing
502
+ // FAIL_HARD_STATUS_INVERSION. Word-boundary regex eliminates
503
+ // these collisions while keeping multi-word keywords ("sign in")
504
+ // and punctuation-flanked matches ("Submit!") working.
454
505
  // ─────────────────────────────────────────────────────────────
455
506
  classifyFunctionalRole(text) {
456
507
  const normalized = text.toLowerCase().trim();
457
- for (const [role, keywords] of ROLE_KEYWORDS) {
458
- for (const kw of keywords) {
459
- if (normalized === kw || normalized.includes(kw)) {
508
+ if (normalized.length === 0)
509
+ return "UNKNOWN";
510
+ for (const { role, regexes } of ROLE_KEYWORD_REGEXES) {
511
+ for (const re of regexes) {
512
+ if (re.test(normalized))
460
513
  return role;
461
- }
462
514
  }
463
515
  }
464
516
  return "UNKNOWN";
@@ -1 +1 @@
1
- {"version":3,"file":"SourceUpdater.d.ts","sourceRoot":"","sources":["../../src/services/SourceUpdater.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAMtD,UAAU,YAAY;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,wEAAwE;IACxE,cAAc,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,UAAU,aAAa;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AA0YD,qBAAa,aAAa;IAMxB,MAAM,CAAC,MAAM,CACX,MAAM,EAAE,aAAa,EACrB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,MAAM,EACnB,WAAW,CAAC,EAAE,MAAM,EACpB,mBAAmB,CAAC,EAAE,MAAM,EAC5B,UAAU,CAAC,EAAE,eAAe,EAAE,EAC9B,aAAa,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EACpD,kBAAkB,CAAC,EAAE,iBAAiB,EAAE,EACxC,iBAAiB,CAAC,EAAE,iBAAiB,EAAE,EACvC,cAAc,CAAC,EAAE,SAAS,GAAG,aAAa,GAAG,QAAQ,EACrD,UAAU,CAAC,EAAE,OAAO,GACnB,YAAY;IAoDf,OAAO,CAAC,MAAM,CAAC,gBAAgB;IAwC/B,OAAO,CAAC,MAAM,CAAC,WAAW;IAwS1B,OAAO,CAAC,MAAM,CAAC,2BAA2B;IA8E1C,OAAO,CAAC,MAAM,CAAC,wBAAwB;IA8EvC,OAAO,CAAC,MAAM,CAAC,sBAAsB;IAoErC,OAAO,CAAC,MAAM,CAAC,kBAAkB;IAsBjC,OAAO,CAAC,MAAM,CAAC,qBAAqB;IA6DpC,OAAO,CAAC,MAAM,CAAC,qBAAqB;IAuCpC,OAAO,CAAC,MAAM,CAAC,wBAAwB;IA6CvC,OAAO,CAAC,MAAM,CAAC,sBAAsB;IAqCrC,OAAO,CAAC,MAAM,CAAC,sBAAsB;IAqGrC,OAAO,CAAC,MAAM,CAAC,kBAAkB;IA0BjC,OAAO,CAAC,MAAM,CAAC,uBAAuB;IAatC,MAAM,CAAC,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAgBlD,MAAM,CAAC,cAAc,CACnB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,YAAY;IA0Cf,0FAA0F;IAC1F,MAAM,CAAC,eAAe,IAAI,IAAI;CAG/B"}
1
+ {"version":3,"file":"SourceUpdater.d.ts","sourceRoot":"","sources":["../../src/services/SourceUpdater.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AA4BtD,UAAU,YAAY;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,wEAAwE;IACxE,cAAc,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,UAAU,aAAa;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAqZD,qBAAa,aAAa;IAMxB,MAAM,CAAC,MAAM,CACX,MAAM,EAAE,aAAa,EACrB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,MAAM,EACnB,WAAW,CAAC,EAAE,MAAM,EACpB,mBAAmB,CAAC,EAAE,MAAM,EAC5B,UAAU,CAAC,EAAE,eAAe,EAAE,EAC9B,aAAa,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EACpD,kBAAkB,CAAC,EAAE,iBAAiB,EAAE,EACxC,iBAAiB,CAAC,EAAE,iBAAiB,EAAE,EACvC,cAAc,CAAC,EAAE,SAAS,GAAG,aAAa,GAAG,QAAQ,EACrD,UAAU,CAAC,EAAE,OAAO,GACnB,YAAY;IAoDf,OAAO,CAAC,MAAM,CAAC,gBAAgB;IAwC/B,OAAO,CAAC,MAAM,CAAC,WAAW;IAiT1B,OAAO,CAAC,MAAM,CAAC,2BAA2B;IA8E1C,OAAO,CAAC,MAAM,CAAC,wBAAwB;IA8EvC,OAAO,CAAC,MAAM,CAAC,sBAAsB;IAoErC,OAAO,CAAC,MAAM,CAAC,kBAAkB;IAsBjC,OAAO,CAAC,MAAM,CAAC,qBAAqB;IA6DpC,OAAO,CAAC,MAAM,CAAC,qBAAqB;IAuCpC,OAAO,CAAC,MAAM,CAAC,wBAAwB;IA6CvC,OAAO,CAAC,MAAM,CAAC,sBAAsB;IAqCrC,OAAO,CAAC,MAAM,CAAC,sBAAsB;IAqGrC,OAAO,CAAC,MAAM,CAAC,kBAAkB;IA0BjC,OAAO,CAAC,MAAM,CAAC,uBAAuB;IAatC,MAAM,CAAC,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAgBlD,MAAM,CAAC,cAAc,CACnB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,YAAY;IA0Cf,0FAA0F;IAC1F,MAAM,CAAC,eAAe,IAAI,IAAI;CAG/B"}
@@ -38,6 +38,23 @@ const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const child_process_1 = require("child_process");
40
40
  const ASTSourceUpdater_1 = require("./ASTSourceUpdater");
41
+ const DryRunGuard_1 = require("../config/DryRunGuard");
42
+ const WorkspaceSnapshotService_1 = require("./WorkspaceSnapshotService");
43
+ const SelaError_1 = require("../errors/SelaError");
44
+ // Centralised write gate — every disk-mutating `fs.writeFileSync` call in
45
+ // this module routes through `persistFile` so the SELA_DRY_RUN bypass is
46
+ // enforced in one place, and the Clean-Room WorkspaceSnapshotService
47
+ // records contentBefore/contentAfter for isolated in-memory diffing.
48
+ function persistFile(filePath, content) {
49
+ if (DryRunGuard_1.DryRunGuard.active()) {
50
+ DryRunGuard_1.DryRunGuard.logSkippedWrite("writeFileSync", filePath);
51
+ return false;
52
+ }
53
+ WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.preWrite(filePath);
54
+ fs.writeFileSync(filePath, content, "utf8");
55
+ WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.postWrite(filePath);
56
+ return true;
57
+ }
41
58
  // ─────────────────────────────────────────────────────────────────
42
59
  // QUOTE MANAGEMENT
43
60
  // ─────────────────────────────────────────────────────────────────
@@ -301,6 +318,13 @@ function updateTemplateLiteralPreservingVars(line, oldSelector, newSelector, dom
301
318
  // ─────────────────────────────────────────────────────────────────
302
319
  function runGitOperations(absoluteFilePath, createBranch, relativeFilePath) {
303
320
  const filename = path.basename(relativeFilePath);
321
+ // Dry-run gate — suppress branch creation, `git add`, and auto-commit.
322
+ // Mirrors the persistFile() skip so a dry run is fully observable in CI
323
+ // without ever producing a commit.
324
+ if (DryRunGuard_1.DryRunGuard.active()) {
325
+ console.log(`[SourceUpdater] 🌵 Dry Run — skip git operations for ${filename}`);
326
+ return;
327
+ }
304
328
  try {
305
329
  // Bail out if HEAD is detached (CI SHA checkout, rebase mid-flight, etc.)
306
330
  try {
@@ -367,7 +391,7 @@ class SourceUpdater {
367
391
  const indent = lines[lineIdx].match(/^(\s*)/)?.[1] ?? "";
368
392
  const comment = `${indent}// TODO [Sela]: replace "${oldSelector}" with "${newSelector}"`;
369
393
  lines.splice(lineIdx, 0, comment);
370
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
394
+ persistFile(filePath, lines.join("\n"));
371
395
  console.log(`[SourceUpdater] 💬 Comment-only: inserted TODO at line ${caller.line} in ${path.basename(filePath)}`);
372
396
  return {
373
397
  success: false,
@@ -441,6 +465,13 @@ class SourceUpdater {
441
465
  console.log(`[SourceUpdater] ⚠️ AST engine exhausted (${astResult.reason}), falling back to Regex`);
442
466
  }
443
467
  catch (astError) {
468
+ // Developer pinned the failing statement with `// sela-fail-fast` —
469
+ // propagate the directive instead of swallowing into the Regex
470
+ // fallback chain (which would defeat the whole point of the gate).
471
+ if ((0, SelaError_1.isSelaErrorFrom)(astError, "ASTUpdater") &&
472
+ astError.code === SelaError_1.AST_UPDATER_CODES.HEAL_DISABLED_BY_DIRECTIVE) {
473
+ throw astError;
474
+ }
444
475
  console.warn(`[SourceUpdater] ⚠️ AST engine error: ${astError.message}. Falling back to Regex`);
445
476
  }
446
477
  // ── 2. Regex Fallback ──────────────────────────────────────────
@@ -452,7 +483,7 @@ class SourceUpdater {
452
483
  if (contentChange) {
453
484
  const assertResult = SourceUpdater.strategyAssertionHealing(lines, 1, contentChange, true);
454
485
  if (assertResult.success) {
455
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
486
+ persistFile(filePath, lines.join("\n"));
456
487
  console.log(`[SourceUpdater] ✅ Regex Strategy G (Assertion Healing) SUCCESS`);
457
488
  return assertResult;
458
489
  }
@@ -479,7 +510,7 @@ class SourceUpdater {
479
510
  const layeredResult = SourceUpdater.strategyLayeredHealing(lines, callerLine, oldSelector, aiSegments, fullSelectorContext);
480
511
  if (layeredResult.success) {
481
512
  tryAssertionHealing();
482
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
513
+ persistFile(filePath, lines.join("\n"));
483
514
  console.log(`[SourceUpdater] ✅ Strategy 0 (Layered Healing) SUCCESS — line ${layeredResult.lineUpdated + 1}`);
484
515
  return layeredResult;
485
516
  }
@@ -488,7 +519,7 @@ class SourceUpdater {
488
519
  const chainCollapseResult = SourceUpdater.strategyChainCollapse_Regex(lines, callerLine, oldSelector, effectiveNewSelector);
489
520
  if (chainCollapseResult.success) {
490
521
  tryAssertionHealing();
491
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
522
+ persistFile(filePath, lines.join("\n"));
492
523
  console.log(`[SourceUpdater] ✅ Strategy F (Chain Collapse) SUCCESS`);
493
524
  return chainCollapseResult;
494
525
  }
@@ -496,7 +527,7 @@ class SourceUpdater {
496
527
  const chainResult = SourceUpdater.strategyChainCollapse(lines, callerLine, oldSelector, effectiveNewSelector);
497
528
  if (chainResult.success) {
498
529
  tryAssertionHealing();
499
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
530
+ persistFile(filePath, lines.join("\n"));
500
531
  console.log(`[SourceUpdater] ✅ Strategy 1 (Chain Collapse) SUCCESS`);
501
532
  return chainResult;
502
533
  }
@@ -504,7 +535,7 @@ class SourceUpdater {
504
535
  const directResult = SourceUpdater.strategyDirectLiteral(lines, callerLine, oldSelector, effectiveNewSelector);
505
536
  if (directResult.success) {
506
537
  tryAssertionHealing();
507
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
538
+ persistFile(filePath, lines.join("\n"));
508
539
  console.log(`[SourceUpdater] ✅ Strategy 2 (Direct Literal) SUCCESS`);
509
540
  return directResult;
510
541
  }
@@ -512,7 +543,7 @@ class SourceUpdater {
512
543
  const varResult = SourceUpdater.strategyUpstreamVariable(lines, callerLine, oldSelector, effectiveNewSelector);
513
544
  if (varResult.success) {
514
545
  tryAssertionHealing();
515
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
546
+ persistFile(filePath, lines.join("\n"));
516
547
  console.log(`[SourceUpdater] ✅ Strategy 3 (Upstream Variable) SUCCESS`);
517
548
  return varResult;
518
549
  }
@@ -520,7 +551,7 @@ class SourceUpdater {
520
551
  const chainDedupResult = SourceUpdater.strategyChainedLocator(lines, callerLine, oldSelector, effectiveNewSelector);
521
552
  if (chainDedupResult.success) {
522
553
  tryAssertionHealing();
523
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
554
+ persistFile(filePath, lines.join("\n"));
524
555
  console.log(`[SourceUpdater] ✅ Strategy 4 (Chained Dedup) SUCCESS`);
525
556
  return chainDedupResult;
526
557
  }
@@ -528,7 +559,7 @@ class SourceUpdater {
528
559
  const fnResult = SourceUpdater.strategyFunctionReturn(lines, callerLine, oldSelector, effectiveNewSelector, domContext, dnaAttrHint);
529
560
  if (fnResult.success) {
530
561
  tryAssertionHealing();
531
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
562
+ persistFile(filePath, lines.join("\n"));
532
563
  console.log(`[SourceUpdater] ✅ Strategy 5 (Function Return) SUCCESS`);
533
564
  return fnResult;
534
565
  }
@@ -536,7 +567,7 @@ class SourceUpdater {
536
567
  const scanResult = SourceUpdater.strategyGlobalScan(lines, oldSelector, effectiveNewSelector);
537
568
  if (scanResult.success) {
538
569
  tryAssertionHealing();
539
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
570
+ persistFile(filePath, lines.join("\n"));
540
571
  console.log(`[SourceUpdater] ✅ Strategy 6 (Global Scan) SUCCESS`);
541
572
  return scanResult;
542
573
  }
@@ -544,7 +575,7 @@ class SourceUpdater {
544
575
  if (contentChange) {
545
576
  const assertResult = SourceUpdater.strategyAssertionHealing(lines, callerLine, contentChange);
546
577
  if (assertResult.success) {
547
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
578
+ persistFile(filePath, lines.join("\n"));
548
579
  console.log(`[SourceUpdater] ✅ Strategy G (Assertion Healing) SUCCESS`);
549
580
  return assertResult;
550
581
  }
@@ -1008,7 +1039,7 @@ class SourceUpdater {
1008
1039
  if (pattern.test(lines[i])) {
1009
1040
  const safeNew = sanitizeForInjection(newArgument, q);
1010
1041
  lines[i] = lines[i].replace(pattern, `$1${safeNew}$2`);
1011
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
1042
+ persistFile(filePath, lines.join("\n"));
1012
1043
  return { success: true, reason: "argument updated", lineUpdated: i };
1013
1044
  }
1014
1045
  }
@@ -1016,7 +1047,7 @@ class SourceUpdater {
1016
1047
  if (pattern.test(lines[i])) {
1017
1048
  const safeNew = sanitizeForInjection(newArgument, q);
1018
1049
  lines[i] = lines[i].replace(pattern, `$1${safeNew}$2`);
1019
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
1050
+ persistFile(filePath, lines.join("\n"));
1020
1051
  return { success: true, reason: "argument updated", lineUpdated: i };
1021
1052
  }
1022
1053
  }
@@ -0,0 +1,71 @@
1
+ export interface FileSnapshot {
2
+ /** Absolute, normalised path of the tracked file. */
3
+ absolutePath: string;
4
+ /** Project-relative path (posix slashes) — for diff headers + PR commits. */
5
+ relativePath: string;
6
+ /**
7
+ * Disk content captured BEFORE Sela's first write to this file.
8
+ * May contain the developer's uncommitted edits — that's the point.
9
+ */
10
+ contentBefore: string;
11
+ /** Disk content captured AFTER Sela's latest write. `null` until first postWrite(). */
12
+ contentAfter: string | null;
13
+ /** Content of the file at `HEAD` (last committed) — `null` when untracked / no git. */
14
+ headContent: string | null;
15
+ /** True when `contentBefore !== headContent`, i.e. developer had dirty edits. */
16
+ hadUncommittedChanges: boolean;
17
+ /** True when the file did not exist on disk before Sela touched it. */
18
+ createdBySela: boolean;
19
+ }
20
+ export declare class WorkspaceSnapshotService {
21
+ private snapshots;
22
+ private cwd;
23
+ constructor(cwd?: string);
24
+ /** Override the cwd used for relative-path + HEAD lookups (tests). */
25
+ setCwd(cwd: string): void;
26
+ /**
27
+ * Record the pre-mutation state of `filePath` if not already tracked.
28
+ * Idempotent — only the FIRST call per file matters.
29
+ */
30
+ preWrite(filePath: string): void;
31
+ /**
32
+ * Refresh the post-mutation state. Safe to call multiple times — each
33
+ * call overwrites `contentAfter` with the latest disk state.
34
+ */
35
+ postWrite(filePath: string): void;
36
+ /** Lookup by absolute or relative path. Returns `null` when untracked. */
37
+ getSnapshot(filePath: string): FileSnapshot | null;
38
+ /** Every tracked snapshot. Insertion order. */
39
+ getAllSnapshots(): FileSnapshot[];
40
+ /**
41
+ * Did Sela actually mutate this file? True when contentAfter exists and
42
+ * differs from contentBefore.
43
+ */
44
+ isMutated(filePath: string): boolean;
45
+ /** All snapshots with a real mutation recorded. */
46
+ getMutatedSnapshots(): FileSnapshot[];
47
+ /**
48
+ * Rewind every tracked file on disk to `contentBefore` — restoring the
49
+ * developer's pre-Sela workspace state byte-for-byte.
50
+ */
51
+ restoreOriginal(): void;
52
+ /**
53
+ * Rewind a SINGLE tracked file on disk to `contentBefore`. Used by the
54
+ * Local DX interactive review flow when the developer rejects a fix.
55
+ * Returns true when the rewind happened, false when the file is untracked.
56
+ */
57
+ restoreOriginalByPath(filePath: string): boolean;
58
+ private rewindSnapshot;
59
+ /**
60
+ * Forward every tracked file on disk to `contentAfter` (no-op for files
61
+ * that were never written). Used by PRAutomationService to reapply
62
+ * Sela's mutations after a `git stash pop` round-trip.
63
+ */
64
+ restoreMutated(): void;
65
+ clear(): void;
66
+ private normalise;
67
+ private toRelative;
68
+ private readHeadContent;
69
+ }
70
+ export declare const sharedWorkspaceSnapshot: WorkspaceSnapshotService;
71
+ //# sourceMappingURL=WorkspaceSnapshotService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WorkspaceSnapshotService.d.ts","sourceRoot":"","sources":["../../src/services/WorkspaceSnapshotService.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,YAAY;IAC3B,qDAAqD;IACrD,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,uFAAuF;IACvF,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,iFAAiF;IACjF,qBAAqB,EAAE,OAAO,CAAC;IAC/B,uEAAuE;IACvE,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,qBAAa,wBAAwB;IACnC,OAAO,CAAC,SAAS,CAAmC;IACpD,OAAO,CAAC,GAAG,CAAS;gBAER,GAAG,GAAE,MAAsB;IAIvC,sEAAsE;IACtE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIzB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAqBhC;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IASjC,0EAA0E;IAC1E,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IAIlD,+CAA+C;IAC/C,eAAe,IAAI,YAAY,EAAE;IAIjC;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAMpC,mDAAmD;IACnD,mBAAmB,IAAI,YAAY,EAAE;IAMrC;;;OAGG;IACH,eAAe,IAAI,IAAI;IAMvB;;;;OAIG;IACH,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAOhD,OAAO,CAAC,cAAc;IAUtB;;;;OAIG;IACH,cAAc,IAAI,IAAI;IAOtB,KAAK,IAAI,IAAI;IAQb,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,UAAU;IAKlB,OAAO,CAAC,eAAe;CAcxB;AAED,eAAO,MAAM,uBAAuB,0BAAiC,CAAC"}
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ // src/services/WorkspaceSnapshotService.ts
3
+ //
4
+ // Clean-Room Architecture — file 1 of 2.
5
+ //
6
+ // Tracks the EXACT content of every file Sela mutates during a session, so:
7
+ // • The HTML report diff can be generated from an isolated in-memory diff
8
+ // (developer's dirty pre-existing edits NEVER bleed in).
9
+ // • PRAutomationService can restore the developer's pre-Sela workspace
10
+ // byte-for-byte after a PR is opened on an isolated branch.
11
+ //
12
+ // Lifecycle:
13
+ // preWrite(path) — first call per file records `contentBefore` (disk
14
+ // snapshot right before Sela touches it) + HEAD blob.
15
+ // postWrite(path) — refreshes `contentAfter` (latest disk state).
16
+ // restoreOriginal()— writes contentBefore back to every tracked file.
17
+ // restoreMutated() — writes contentAfter back to every tracked file.
18
+ // clear() — empties the ledger.
19
+ //
20
+ // Singleton `sharedWorkspaceSnapshot` is the canonical instance the
21
+ // SourceUpdater / ASTSourceUpdater write hooks talk to.
22
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ var desc = Object.getOwnPropertyDescriptor(m, k);
25
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
26
+ desc = { enumerable: true, get: function() { return m[k]; } };
27
+ }
28
+ Object.defineProperty(o, k2, desc);
29
+ }) : (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ o[k2] = m[k];
32
+ }));
33
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
34
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
35
+ }) : function(o, v) {
36
+ o["default"] = v;
37
+ });
38
+ var __importStar = (this && this.__importStar) || (function () {
39
+ var ownKeys = function(o) {
40
+ ownKeys = Object.getOwnPropertyNames || function (o) {
41
+ var ar = [];
42
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
43
+ return ar;
44
+ };
45
+ return ownKeys(o);
46
+ };
47
+ return function (mod) {
48
+ if (mod && mod.__esModule) return mod;
49
+ var result = {};
50
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
51
+ __setModuleDefault(result, mod);
52
+ return result;
53
+ };
54
+ })();
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.sharedWorkspaceSnapshot = exports.WorkspaceSnapshotService = void 0;
57
+ const fs = __importStar(require("fs"));
58
+ const path = __importStar(require("path"));
59
+ const child_process_1 = require("child_process");
60
+ class WorkspaceSnapshotService {
61
+ snapshots = new Map();
62
+ cwd;
63
+ constructor(cwd = process.cwd()) {
64
+ this.cwd = cwd;
65
+ }
66
+ /** Override the cwd used for relative-path + HEAD lookups (tests). */
67
+ setCwd(cwd) {
68
+ this.cwd = cwd;
69
+ }
70
+ /**
71
+ * Record the pre-mutation state of `filePath` if not already tracked.
72
+ * Idempotent — only the FIRST call per file matters.
73
+ */
74
+ preWrite(filePath) {
75
+ const abs = this.normalise(filePath);
76
+ if (this.snapshots.has(abs))
77
+ return;
78
+ const exists = fs.existsSync(abs);
79
+ const contentBefore = exists ? fs.readFileSync(abs, "utf8") : "";
80
+ const headContent = this.readHeadContent(abs);
81
+ const hadUncommittedChanges = headContent !== null && headContent !== contentBefore;
82
+ this.snapshots.set(abs, {
83
+ absolutePath: abs,
84
+ relativePath: this.toRelative(abs),
85
+ contentBefore,
86
+ contentAfter: null,
87
+ headContent,
88
+ hadUncommittedChanges,
89
+ createdBySela: !exists,
90
+ });
91
+ }
92
+ /**
93
+ * Refresh the post-mutation state. Safe to call multiple times — each
94
+ * call overwrites `contentAfter` with the latest disk state.
95
+ */
96
+ postWrite(filePath) {
97
+ const abs = this.normalise(filePath);
98
+ const snap = this.snapshots.get(abs);
99
+ if (!snap)
100
+ return;
101
+ snap.contentAfter = fs.existsSync(abs)
102
+ ? fs.readFileSync(abs, "utf8")
103
+ : "";
104
+ }
105
+ /** Lookup by absolute or relative path. Returns `null` when untracked. */
106
+ getSnapshot(filePath) {
107
+ return this.snapshots.get(this.normalise(filePath)) ?? null;
108
+ }
109
+ /** Every tracked snapshot. Insertion order. */
110
+ getAllSnapshots() {
111
+ return Array.from(this.snapshots.values());
112
+ }
113
+ /**
114
+ * Did Sela actually mutate this file? True when contentAfter exists and
115
+ * differs from contentBefore.
116
+ */
117
+ isMutated(filePath) {
118
+ const snap = this.getSnapshot(filePath);
119
+ if (!snap || snap.contentAfter === null)
120
+ return false;
121
+ return snap.contentAfter !== snap.contentBefore;
122
+ }
123
+ /** All snapshots with a real mutation recorded. */
124
+ getMutatedSnapshots() {
125
+ return this.getAllSnapshots().filter((s) => s.contentAfter !== null && s.contentAfter !== s.contentBefore);
126
+ }
127
+ /**
128
+ * Rewind every tracked file on disk to `contentBefore` — restoring the
129
+ * developer's pre-Sela workspace state byte-for-byte.
130
+ */
131
+ restoreOriginal() {
132
+ for (const snap of this.snapshots.values()) {
133
+ this.rewindSnapshot(snap);
134
+ }
135
+ }
136
+ /**
137
+ * Rewind a SINGLE tracked file on disk to `contentBefore`. Used by the
138
+ * Local DX interactive review flow when the developer rejects a fix.
139
+ * Returns true when the rewind happened, false when the file is untracked.
140
+ */
141
+ restoreOriginalByPath(filePath) {
142
+ const snap = this.getSnapshot(filePath);
143
+ if (!snap)
144
+ return false;
145
+ this.rewindSnapshot(snap);
146
+ return true;
147
+ }
148
+ rewindSnapshot(snap) {
149
+ if (snap.createdBySela) {
150
+ if (fs.existsSync(snap.absolutePath)) {
151
+ fs.unlinkSync(snap.absolutePath);
152
+ }
153
+ return;
154
+ }
155
+ fs.writeFileSync(snap.absolutePath, snap.contentBefore, "utf8");
156
+ }
157
+ /**
158
+ * Forward every tracked file on disk to `contentAfter` (no-op for files
159
+ * that were never written). Used by PRAutomationService to reapply
160
+ * Sela's mutations after a `git stash pop` round-trip.
161
+ */
162
+ restoreMutated() {
163
+ for (const snap of this.snapshots.values()) {
164
+ if (snap.contentAfter === null)
165
+ continue;
166
+ fs.writeFileSync(snap.absolutePath, snap.contentAfter, "utf8");
167
+ }
168
+ }
169
+ clear() {
170
+ this.snapshots.clear();
171
+ }
172
+ // ────────────────────────────────────────────────────────────────
173
+ // INTERNAL
174
+ // ────────────────────────────────────────────────────────────────
175
+ normalise(raw) {
176
+ if (!raw)
177
+ return raw;
178
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(this.cwd, raw);
179
+ return path.normalize(abs);
180
+ }
181
+ toRelative(abs) {
182
+ const rel = path.relative(this.cwd, abs);
183
+ return rel.split(path.sep).join("/");
184
+ }
185
+ readHeadContent(abs) {
186
+ try {
187
+ const rel = this.toRelative(abs);
188
+ const out = (0, child_process_1.execSync)(`git show HEAD:"${rel}"`, {
189
+ cwd: this.cwd,
190
+ encoding: "utf8",
191
+ stdio: ["pipe", "pipe", "pipe"],
192
+ windowsHide: true,
193
+ });
194
+ return out.toString();
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ }
201
+ exports.WorkspaceSnapshotService = WorkspaceSnapshotService;
202
+ exports.sharedWorkspaceSnapshot = new WorkspaceSnapshotService();