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
@@ -54,6 +54,8 @@ exports.handleBugDetected = handleBugDetected;
54
54
  const fs = __importStar(require("fs"));
55
55
  const path = __importStar(require("path"));
56
56
  const child_process_1 = require("child_process");
57
+ const WorkspaceSnapshotService_1 = require("./WorkspaceSnapshotService");
58
+ const IsolatedDiff_1 = require("../utils/IsolatedDiff");
57
59
  // ═══════════════════════════════════════════════════════════════════
58
60
  // SHELL HELPERS
59
61
  // ═══════════════════════════════════════════════════════════════════
@@ -327,7 +329,19 @@ function buildDefaultBody(heals, branchInfo, reportLink, decision) {
327
329
  lines.push(`Generated automatically by \`sela-core\`.`);
328
330
  return lines.join("\n");
329
331
  }
330
- async function execute(cfg, decision, heals, branchInfo, ctx) {
332
+ /**
333
+ * Clean-Room execution lifecycle.
334
+ *
335
+ * The legacy flow committed the developer's whole dirty working tree onto
336
+ * the PR branch — polluting reviewers with `test.only`, commented debug
337
+ * blocks, etc. This refactor isolates Sela's specific AST changes onto an
338
+ * ephemeral branch, leaves the developer's local workspace byte-identical
339
+ * to its pre-Sela state, and is wrapped in a try/finally that restores
340
+ * the workspace even on mid-flight crashes.
341
+ */
342
+ async function execute(cfg, decision, heals, branchInfo, ctx,
343
+ /** Injectable for tests — defaults to the module singleton. */
344
+ workspace = WorkspaceSnapshotService_1.sharedWorkspaceSnapshot) {
331
345
  const result = {
332
346
  effective: decision.effective,
333
347
  prUrl: null,
@@ -355,14 +369,13 @@ async function execute(cfg, decision, heals, branchInfo, ctx) {
355
369
  console.warn(`[Sela PR] ⚠️ Detached HEAD — skipping git operations.`);
356
370
  return result;
357
371
  }
358
- // ── Stage all healed files (relative paths from event records) ────
359
- const filesToStage = Array.from(new Set(heals.map((h) => h.sourceFile).filter((f) => f && f.length > 0)));
360
- if (filesToStage.length === 0) {
361
- result.errors.push("no files to stage");
362
- console.warn(`[Sela PR] ⚠️ No file paths in HEALED events — skipping.`);
372
+ const mutatedSnaps = workspace.getMutatedSnapshots();
373
+ if (mutatedSnaps.length === 0) {
374
+ result.errors.push("no mutated files tracked");
375
+ console.warn(`[Sela PR] ⚠️ WorkspaceSnapshotService reports no mutated files skipping.`);
363
376
  return result;
364
377
  }
365
- // Render title — use first heal as representative
378
+ // Render title / body up front needed by all branches below.
366
379
  const first = heals[0];
367
380
  const titleVars = {
368
381
  testName: first.testTitle ?? "automation",
@@ -381,90 +394,268 @@ async function execute(cfg, decision, heals, branchInfo, ctx) {
381
394
  reasoning: heals.map((h) => h.aiExplanation).join("\n\n"),
382
395
  })
383
396
  : buildDefaultBody(heals, branchInfo, reportLink, decision);
384
- // ── directCommit ────────────────────────────────────────────────
385
397
  if (decision.effective === "directCommit") {
386
- for (const f of filesToStage) {
387
- const add = sh(`git add "${f}"`, ctx.cwd);
388
- if (!add.ok)
389
- result.errors.push(`git add ${f}: ${add.stderr}`);
390
- }
391
- const commit = sh(`git commit -m ${shellQuote(title)}`, ctx.cwd);
392
- if (!commit.ok) {
393
- result.errors.push(`git commit: ${commit.stderr}`);
394
- console.warn(`[Sela PR] ⚠️ git commit failed: ${commit.stderr}`);
395
- return result;
396
- }
397
- console.log(`[Sela PR] ✅ Direct commit on '${branchInfo.branch}': ${title}`);
398
- const push = sh(`git push`, ctx.cwd);
399
- if (!push.ok) {
400
- result.errors.push(`git push: ${push.stderr}`);
401
- console.warn(`[Sela PR] ⚠️ git push failed: ${push.stderr}`);
402
- return result;
403
- }
404
- console.log(`[Sela PR] 🚀 Pushed to '${branchInfo.branch}'`);
405
- return result;
398
+ return runCleanRoom({
399
+ mode: "directCommit",
400
+ cfg,
401
+ ctx,
402
+ mutatedSnaps,
403
+ title,
404
+ body,
405
+ branchInfo,
406
+ decision,
407
+ result,
408
+ });
406
409
  }
407
- // ── pr / draftPR ────────────────────────────────────────────────
408
410
  if (decision.effective === "pr" || decision.effective === "draftPR") {
409
411
  if (!ghAvailable(ctx.cwd)) {
410
412
  result.errors.push("gh CLI not available");
411
413
  console.warn(`[Sela PR] ⚠️ gh CLI not installed — cannot open PR. Run \`gh auth login\` after install.`);
412
414
  return result;
413
415
  }
414
- const id = String(Date.now());
415
- const branchName = render(cfg.branchNameTemplate, { id, timestamp: id });
416
- const checkout = sh(`git checkout -b ${branchName}`, ctx.cwd);
417
- if (!checkout.ok) {
418
- result.errors.push(`git checkout: ${checkout.stderr}`);
419
- console.warn(`[Sela PR] ⚠️ Could not create branch '${branchName}': ${checkout.stderr}`);
420
- return result;
416
+ return runCleanRoom({
417
+ mode: decision.effective,
418
+ cfg,
419
+ ctx,
420
+ mutatedSnaps,
421
+ title,
422
+ body,
423
+ branchInfo,
424
+ decision,
425
+ result,
426
+ });
427
+ }
428
+ return result;
429
+ }
430
+ async function runCleanRoom(args) {
431
+ const { mode, cfg, ctx, mutatedSnaps, title, body, branchInfo, decision, result } = args;
432
+ const id = String(Date.now());
433
+ const stashLabel = `sela:clean-room-${id}`;
434
+ const originalBranch = currentBranch(ctx.cwd);
435
+ const targetBranch = mode === "directCommit"
436
+ ? originalBranch
437
+ : render(cfg.branchNameTemplate, { id, timestamp: id });
438
+ // Lifecycle state — used by the finally{} restoration block.
439
+ let didRestoreOriginal = false;
440
+ let didStash = false;
441
+ let didSwitchToTarget = false;
442
+ let didCommit = false;
443
+ try {
444
+ // 1. State Checkpoint — rewind developer's mutated files to pre-Sela
445
+ // state on disk. Other dirty files (unrelated to Sela) stay where
446
+ // they are; the subsequent stash will sweep them up.
447
+ workspaceRestoreOriginal(mutatedSnaps);
448
+ didRestoreOriginal = true;
449
+ // 2. Branch Isolation — stash everything (tracked + untracked) so the
450
+ // worktree mirrors HEAD when we switch / branch off.
451
+ const stash = sh(`git stash push -u -m ${shellQuote(stashLabel)}`, ctx.cwd);
452
+ if (stash.ok) {
453
+ didStash = !/No local changes to save/i.test(stash.stdout);
454
+ }
455
+ else {
456
+ // Stash failures are non-fatal when the worktree was already clean.
457
+ console.debug?.(`[Sela PR] stash skipped: ${stash.stderr}`);
458
+ }
459
+ if (mode !== "directCommit") {
460
+ const checkout = sh(`git checkout -b ${targetBranch}`, ctx.cwd);
461
+ if (!checkout.ok) {
462
+ throw new Error(`git checkout -b ${targetBranch}: ${checkout.stderr}`);
463
+ }
464
+ didSwitchToTarget = true;
421
465
  }
422
- for (const f of filesToStage) {
423
- const add = sh(`git add "${f}"`, ctx.cwd);
466
+ // 3. Targeted Hunk Patching — apply ONLY Sela's blocks to each file's
467
+ // clean HEAD content. Refuses to write if dirty edits collide with
468
+ // Sela's mutation site (block not found / not unique).
469
+ for (const snap of mutatedSnaps) {
470
+ const applied = applyMutationToClean(snap);
471
+ if (!applied.ok) {
472
+ throw new Error(`cannot apply Sela mutation to clean base of ${snap.relativePath}: ${applied.reason}`);
473
+ }
474
+ fs.writeFileSync(snap.absolutePath, applied.result, "utf8");
475
+ }
476
+ // 4. Stage, commit, push.
477
+ for (const snap of mutatedSnaps) {
478
+ const add = sh(`git add ${shellQuote(snap.relativePath)}`, ctx.cwd);
424
479
  if (!add.ok)
425
- result.errors.push(`git add ${f}: ${add.stderr}`);
480
+ result.errors.push(`git add ${snap.relativePath}: ${add.stderr}`);
426
481
  }
482
+ configureGitIdentityForCI(ctx.cwd);
427
483
  const commit = sh(`git commit -m ${shellQuote(title)}`, ctx.cwd);
428
484
  if (!commit.ok) {
429
- result.errors.push(`git commit: ${commit.stderr}`);
430
- console.warn(`[Sela PR] ⚠️ git commit failed: ${commit.stderr}`);
431
- return result;
485
+ throw new Error(`git commit: ${commit.stderr}`);
432
486
  }
433
- const push = sh(`git push -u origin ${branchName}`, ctx.cwd);
487
+ didCommit = true;
488
+ console.log(`[Sela PR] ✅ Clean commit on '${targetBranch ?? "(unknown)"}': ${title}`);
489
+ const pushCmd = mode === "directCommit"
490
+ ? `git push`
491
+ : `git push -u origin ${shellQuote(targetBranch)}`;
492
+ const push = sh(pushCmd, ctx.cwd);
434
493
  if (!push.ok) {
435
- result.errors.push(`git push: ${push.stderr}`);
436
- console.warn(`[Sela PR] ⚠️ git push failed: ${push.stderr}`);
437
- return result;
494
+ throw new Error(`git push: ${push.stderr}`);
438
495
  }
439
- result.branchPushed = branchName;
440
- // Write body to a temp file to avoid shell-escaping headaches
441
- const bodyFile = path.join(ctx.cwd, `.sela-pr-body-${id}.md`);
442
- fs.writeFileSync(bodyFile, body, "utf8");
443
- const labelFlag = cfg.labels.length > 0
444
- ? cfg.labels.map((l) => `--label ${shellQuote(l)}`).join(" ")
445
- : "";
446
- const draftFlag = decision.effective === "draftPR" ? "--draft" : "";
447
- const baseFlag = branchInfo.branch ? `--base ${shellQuote(branchInfo.branch)}` : "";
448
- const prCmd = `gh pr create ${draftFlag} ${baseFlag} ` +
449
- `--title ${shellQuote(title)} ` +
450
- `--body-file ${shellQuote(bodyFile)} ` +
451
- `${labelFlag}`;
452
- const pr = sh(prCmd, ctx.cwd);
496
+ if (mode !== "directCommit") {
497
+ result.branchPushed = targetBranch;
498
+ }
499
+ // 5. (PR modes only) open the PR.
500
+ if (mode === "pr" || mode === "draftPR") {
501
+ const prUrl = openPullRequest({
502
+ cfg,
503
+ ctx,
504
+ mode,
505
+ title,
506
+ body,
507
+ branchInfo,
508
+ id,
509
+ result,
510
+ });
511
+ if (prUrl)
512
+ result.prUrl = prUrl;
513
+ }
514
+ }
515
+ catch (err) {
516
+ result.errors.push(err?.message ?? String(err));
517
+ console.warn(`[Sela PR] ⚠️ Clean-room flow failed: ${err?.message ?? err}`);
518
+ }
519
+ finally {
520
+ // 6. Workspace Restoration — ALWAYS attempt, even on mid-flight crash.
521
+ // Order matters:
522
+ // a) Switch back to the developer's original branch (if we left it).
523
+ // b) Pop the stash to restore the dirty pre-Sela working tree.
524
+ // c) Defensively re-write contentBefore for tracked files in case
525
+ // the stash was lost.
453
526
  try {
454
- fs.unlinkSync(bodyFile);
527
+ if (didSwitchToTarget && originalBranch) {
528
+ const back = sh(`git checkout ${shellQuote(originalBranch)}`, ctx.cwd);
529
+ if (!back.ok) {
530
+ console.warn(`[Sela PR] ⚠️ Could not switch back to '${originalBranch}': ${back.stderr}`);
531
+ }
532
+ }
533
+ if (didStash) {
534
+ const pop = sh(`git stash pop`, ctx.cwd);
535
+ if (!pop.ok) {
536
+ console.warn(`[Sela PR] ⚠️ git stash pop failed: ${pop.stderr}`);
537
+ }
538
+ }
539
+ if (didRestoreOriginal) {
540
+ // Belt-and-braces — every tracked snapshot back to its dirty
541
+ // pre-Sela state. No-op if stash pop already did the job.
542
+ workspaceRestoreOriginal(mutatedSnaps);
543
+ }
455
544
  }
456
- catch { /* ignore */ }
457
- if (!pr.ok) {
458
- result.errors.push(`gh pr create: ${pr.stderr}`);
459
- console.warn(`[Sela PR] ⚠️ gh pr create failed: ${pr.stderr}`);
460
- return result;
545
+ catch (restoreErr) {
546
+ console.warn(`[Sela PR] ⚠️ Workspace restoration error: ${restoreErr?.message ?? restoreErr}`);
461
547
  }
462
- result.prUrl = pr.stdout.trim();
463
- console.log(`[Sela PR] ✅ ${decision.effective === "draftPR" ? "Draft PR" : "PR"} opened: ${result.prUrl}`);
464
- return result;
548
+ }
549
+ if (!didCommit && result.errors.length === 0) {
550
+ result.errors.push("clean-room flow did not produce a commit");
465
551
  }
466
552
  return result;
467
553
  }
554
+ function openPullRequest(args) {
555
+ const { cfg, ctx, mode, title, body, branchInfo, id, result } = args;
556
+ const bodyFile = path.join(ctx.cwd, `.sela-pr-body-${id}.md`);
557
+ try {
558
+ fs.writeFileSync(bodyFile, body, "utf8");
559
+ }
560
+ catch (err) {
561
+ result.errors.push(`pr body write: ${err?.message ?? err}`);
562
+ return null;
563
+ }
564
+ const labelFlag = cfg.labels.length > 0
565
+ ? cfg.labels.map((l) => `--label ${shellQuote(l)}`).join(" ")
566
+ : "";
567
+ const draftFlag = mode === "draftPR" ? "--draft" : "";
568
+ const baseFlag = branchInfo.branch ? `--base ${shellQuote(branchInfo.branch)}` : "";
569
+ const prCmd = `gh pr create ${draftFlag} ${baseFlag} ` +
570
+ `--title ${shellQuote(title)} ` +
571
+ `--body-file ${shellQuote(bodyFile)} ` +
572
+ `${labelFlag}`;
573
+ const pr = sh(prCmd, ctx.cwd);
574
+ try {
575
+ fs.unlinkSync(bodyFile);
576
+ }
577
+ catch {
578
+ /* ignore */
579
+ }
580
+ if (!pr.ok) {
581
+ result.errors.push(`gh pr create: ${pr.stderr}`);
582
+ console.warn(`[Sela PR] ⚠️ gh pr create failed: ${pr.stderr}`);
583
+ return null;
584
+ }
585
+ const url = pr.stdout.trim();
586
+ console.log(`[Sela PR] ✅ ${mode === "draftPR" ? "Draft PR" : "PR"} opened: ${url}`);
587
+ return url;
588
+ }
589
+ // ═══════════════════════════════════════════════════════════════════
590
+ // CLEAN-ROOM HELPERS
591
+ // ═══════════════════════════════════════════════════════════════════
592
+ function currentBranch(cwd) {
593
+ const r = sh("git rev-parse --abbrev-ref HEAD", cwd);
594
+ if (!r.ok)
595
+ return null;
596
+ const b = r.stdout.trim();
597
+ return b && b !== "HEAD" ? b : null;
598
+ }
599
+ function workspaceRestoreOriginal(snaps) {
600
+ for (const snap of snaps) {
601
+ try {
602
+ if (snap.createdBySela) {
603
+ if (fs.existsSync(snap.absolutePath))
604
+ fs.unlinkSync(snap.absolutePath);
605
+ continue;
606
+ }
607
+ fs.writeFileSync(snap.absolutePath, snap.contentBefore, "utf8");
608
+ }
609
+ catch (err) {
610
+ console.warn(`[Sela PR] ⚠️ Could not restore ${snap.relativePath}: ${err?.message ?? err}`);
611
+ }
612
+ }
613
+ }
614
+ function applyMutationToClean(snap) {
615
+ const after = snap.contentAfter ?? "";
616
+ // File didn't exist on HEAD (created by Sela or untracked). Write the
617
+ // post-mutation content directly — there's no clean base to merge into.
618
+ if (snap.headContent === null) {
619
+ return { ok: true, result: after };
620
+ }
621
+ // No dirty edits — `headContent === contentBefore`, so the mutation
622
+ // applies cleanly as a 1:1 string equivalence.
623
+ if (snap.headContent === snap.contentBefore) {
624
+ return { ok: true, result: after };
625
+ }
626
+ // Dirty edits present — extract Sela's specific change blocks and
627
+ // re-apply them against the clean HEAD content via unique substring
628
+ // replacement. Fails loudly when the dirty edits overlap Sela's site.
629
+ const blocks = (0, IsolatedDiff_1.extractChangeBlocks)(snap.contentBefore, after);
630
+ if (blocks.length === 0) {
631
+ return { ok: true, result: snap.headContent };
632
+ }
633
+ return (0, IsolatedDiff_1.applyChangeBlocks)(snap.headContent, blocks);
634
+ }
635
+ /**
636
+ * CI adapters (GitHub Actions, GitLab, Jenkins) often run with no global
637
+ * git identity. `git commit` then fails with `Please tell me who you are`,
638
+ * tanking the whole heal pipeline. Plant a sela bot identity for THIS
639
+ * commit only when no `user.email` / `user.name` is configured — never
640
+ * overwrite an existing identity.
641
+ */
642
+ function configureGitIdentityForCI(cwd) {
643
+ const isCI = process.env.CI === "true" ||
644
+ process.env.CI === "1" ||
645
+ !!process.env.GITHUB_ACTIONS ||
646
+ !!process.env.GITLAB_CI ||
647
+ !!process.env.JENKINS_URL;
648
+ if (!isCI)
649
+ return;
650
+ const email = sh("git config user.email", cwd);
651
+ if (!email.ok || email.stdout.trim().length === 0) {
652
+ sh(`git config user.email "sela-bot@anthropic.local"`, cwd);
653
+ }
654
+ const name = sh("git config user.name", cwd);
655
+ if (!name.ok || name.stdout.trim().length === 0) {
656
+ sh(`git config user.name "Sela Bot"`, cwd);
657
+ }
658
+ }
468
659
  // ═══════════════════════════════════════════════════════════════════
469
660
  // BUG-DETECTED HANDLING
470
661
  // ═══════════════════════════════════════════════════════════════════
@@ -0,0 +1,44 @@
1
+ import type { WorkspaceSnapshotService } from "./WorkspaceSnapshotService";
2
+ import type { HealedEvent } from "./HealReportService";
3
+ export interface PendingPromptEntry {
4
+ absolutePath: string;
5
+ relativePath: string;
6
+ contentBefore: string;
7
+ contentAfter: string;
8
+ createdBySela: boolean;
9
+ /** 1-based line number for the vscode:// deeplink, or null when unknown. */
10
+ lineNumber: number | null;
11
+ }
12
+ /**
13
+ * Resolve the ledger directory for a given cwd. The directory is created
14
+ * lazily on the first write so we never clutter repos that aren't running
15
+ * Sela.
16
+ */
17
+ export declare function ledgerDir(cwd: string): string;
18
+ /**
19
+ * Persist this worker's mutated snapshots to a fresh JSON file under
20
+ * `<cwd>/.sela/pending-prompts/`. Idempotent within a single worker —
21
+ * subsequent calls in the same process append a NEW file (rare in
22
+ * practice but keeps the contract simple).
23
+ *
24
+ * Returns the absolute path of the written file, or `null` when there is
25
+ * nothing to persist (so callers can skip the prompt entirely in CI /
26
+ * empty-mutation runs).
27
+ */
28
+ export declare function writePendingPromptsLedger(opts: {
29
+ cwd: string;
30
+ workspace: WorkspaceSnapshotService;
31
+ healedEvents: HealedEvent[];
32
+ }): string | null;
33
+ /**
34
+ * Drain every per-worker JSON file under the ledger directory and merge
35
+ * their entries into a single de-duplicated list (keyed by absolutePath).
36
+ * The most-recent contentAfter wins on collision — that's the disk truth.
37
+ *
38
+ * The directory is NOT deleted here so the reporter can inspect entries
39
+ * before calling `clearPendingPromptsLedger()`.
40
+ */
41
+ export declare function readPendingPromptsLedger(cwd: string): PendingPromptEntry[];
42
+ /** Remove the ledger directory and every file beneath it. Idempotent. */
43
+ export declare function clearPendingPromptsLedger(cwd: string): void;
44
+ //# sourceMappingURL=PendingPromptLedger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PendingPromptLedger.d.ts","sourceRoot":"","sources":["../../src/services/PendingPromptLedger.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAEV,wBAAwB,EACzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAID;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,wBAAwB,CAAC;IACpC,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B,GAAG,MAAM,GAAG,IAAI,CAehB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAyB1E;AAED,yEAAyE;AACzE,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAI3D"}
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ // src/services/PendingPromptLedger.ts
3
+ //
4
+ // Cross-process ledger for the Local DX dual-mode lifecycle.
5
+ //
6
+ // Playwright workers run in separate Node processes from the reporter
7
+ // (main process). The in-memory `WorkspaceSnapshotService` ledger is
8
+ // per-process, so we can't drive an interactive prompt from the reporter
9
+ // using only worker-side state.
10
+ //
11
+ // Solution: each worker SERIALISES its mutated FileSnapshots to a JSON
12
+ // file under `<cwd>/.sela/pending-prompts/`, one file per worker process
13
+ // (keyed by pid + monotonic timestamp). The reporter's onEnd hook reads
14
+ // every file in that directory, drives `runInteractiveReview()` against
15
+ // the merged list, then deletes the directory.
16
+ //
17
+ // CRITICAL: workers MUST NOT call `readline` / block on stdin — they have
18
+ // no TTY attached. Blocking the event loop here causes the Playwright
19
+ // test timeout to fire. This ledger is the boundary between silent
20
+ // worker-side execution and TTY-bound reporter-side prompting.
21
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ var desc = Object.getOwnPropertyDescriptor(m, k);
24
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
25
+ desc = { enumerable: true, get: function() { return m[k]; } };
26
+ }
27
+ Object.defineProperty(o, k2, desc);
28
+ }) : (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ o[k2] = m[k];
31
+ }));
32
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
33
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
34
+ }) : function(o, v) {
35
+ o["default"] = v;
36
+ });
37
+ var __importStar = (this && this.__importStar) || (function () {
38
+ var ownKeys = function(o) {
39
+ ownKeys = Object.getOwnPropertyNames || function (o) {
40
+ var ar = [];
41
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
42
+ return ar;
43
+ };
44
+ return ownKeys(o);
45
+ };
46
+ return function (mod) {
47
+ if (mod && mod.__esModule) return mod;
48
+ var result = {};
49
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
50
+ __setModuleDefault(result, mod);
51
+ return result;
52
+ };
53
+ })();
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.ledgerDir = ledgerDir;
56
+ exports.writePendingPromptsLedger = writePendingPromptsLedger;
57
+ exports.readPendingPromptsLedger = readPendingPromptsLedger;
58
+ exports.clearPendingPromptsLedger = clearPendingPromptsLedger;
59
+ const fs = __importStar(require("fs"));
60
+ const path = __importStar(require("path"));
61
+ const LEDGER_SUBDIR = path.join(".sela", "pending-prompts");
62
+ /**
63
+ * Resolve the ledger directory for a given cwd. The directory is created
64
+ * lazily on the first write so we never clutter repos that aren't running
65
+ * Sela.
66
+ */
67
+ function ledgerDir(cwd) {
68
+ return path.join(cwd, LEDGER_SUBDIR);
69
+ }
70
+ /**
71
+ * Persist this worker's mutated snapshots to a fresh JSON file under
72
+ * `<cwd>/.sela/pending-prompts/`. Idempotent within a single worker —
73
+ * subsequent calls in the same process append a NEW file (rare in
74
+ * practice but keeps the contract simple).
75
+ *
76
+ * Returns the absolute path of the written file, or `null` when there is
77
+ * nothing to persist (so callers can skip the prompt entirely in CI /
78
+ * empty-mutation runs).
79
+ */
80
+ function writePendingPromptsLedger(opts) {
81
+ const snaps = opts.workspace.getMutatedSnapshots();
82
+ if (snaps.length === 0)
83
+ return null;
84
+ const entries = snaps.map((s) => snapshotToEntry(s, opts.healedEvents, opts.cwd));
85
+ const dir = ledgerDir(opts.cwd);
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ const fileName = `${process.pid}-${Date.now()}-${randomTag()}.json`;
88
+ const full = path.join(dir, fileName);
89
+ fs.writeFileSync(full, JSON.stringify(entries, null, 2), "utf8");
90
+ return full;
91
+ }
92
+ /**
93
+ * Drain every per-worker JSON file under the ledger directory and merge
94
+ * their entries into a single de-duplicated list (keyed by absolutePath).
95
+ * The most-recent contentAfter wins on collision — that's the disk truth.
96
+ *
97
+ * The directory is NOT deleted here so the reporter can inspect entries
98
+ * before calling `clearPendingPromptsLedger()`.
99
+ */
100
+ function readPendingPromptsLedger(cwd) {
101
+ const dir = ledgerDir(cwd);
102
+ if (!fs.existsSync(dir))
103
+ return [];
104
+ const merged = new Map();
105
+ const files = fs
106
+ .readdirSync(dir)
107
+ .filter((f) => f.endsWith(".json"))
108
+ .sort(); // pid-timestamp prefix gives a stable, time-ordered merge
109
+ for (const f of files) {
110
+ const full = path.join(dir, f);
111
+ try {
112
+ const raw = fs.readFileSync(full, "utf8");
113
+ const parsed = JSON.parse(raw);
114
+ if (!Array.isArray(parsed))
115
+ continue;
116
+ for (const entry of parsed) {
117
+ if (!entry || typeof entry.absolutePath !== "string")
118
+ continue;
119
+ merged.set(path.normalize(entry.absolutePath), entry);
120
+ }
121
+ }
122
+ catch {
123
+ // Corrupt ledger file — skip rather than crashing the whole review.
124
+ }
125
+ }
126
+ return Array.from(merged.values());
127
+ }
128
+ /** Remove the ledger directory and every file beneath it. Idempotent. */
129
+ function clearPendingPromptsLedger(cwd) {
130
+ const dir = ledgerDir(cwd);
131
+ if (!fs.existsSync(dir))
132
+ return;
133
+ fs.rmSync(dir, { recursive: true, force: true });
134
+ }
135
+ // ───────────────────────────────────────────────────────────────────
136
+ // Helpers
137
+ // ───────────────────────────────────────────────────────────────────
138
+ function snapshotToEntry(snap, heals, cwd) {
139
+ return {
140
+ absolutePath: snap.absolutePath,
141
+ relativePath: snap.relativePath,
142
+ contentBefore: snap.contentBefore,
143
+ contentAfter: snap.contentAfter ?? "",
144
+ createdBySela: snap.createdBySela,
145
+ lineNumber: findLineNumberForSnapshot(snap, heals, cwd),
146
+ };
147
+ }
148
+ /**
149
+ * Resolve a 1-based line number for the vscode:// deeplink. Priority:
150
+ * 1. `definitionSite.line` for a heal whose `definitionSite.file`
151
+ * resolves to this snapshot.
152
+ * 2. `newLineNumber` reported on a heal whose `sourceFile` resolves to
153
+ * this snapshot.
154
+ * 3. `null` (link emitted without a line suffix).
155
+ */
156
+ function findLineNumberForSnapshot(snap, heals, cwd) {
157
+ const normSnap = path.normalize(snap.absolutePath);
158
+ for (const heal of heals) {
159
+ if (heal.definitionSite) {
160
+ const defAbs = path.isAbsolute(heal.definitionSite.file)
161
+ ? path.normalize(heal.definitionSite.file)
162
+ : path.normalize(path.resolve(cwd, heal.definitionSite.file));
163
+ if (defAbs === normSnap && heal.definitionSite.line > 0) {
164
+ return heal.definitionSite.line;
165
+ }
166
+ }
167
+ }
168
+ for (const heal of heals) {
169
+ const healAbs = path.isAbsolute(heal.sourceFile)
170
+ ? path.normalize(heal.sourceFile)
171
+ : path.normalize(path.resolve(cwd, heal.sourceFile));
172
+ if (healAbs === normSnap && heal.newLineNumber > 0) {
173
+ return heal.newLineNumber;
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+ function randomTag() {
179
+ return Math.random().toString(36).slice(2, 8);
180
+ }