sela-core 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/ErrorHandler.d.ts +1 -1
- package/dist/cli/ErrorHandler.js +14 -14
- package/dist/cli/commands/bulk.d.ts +1 -1
- package/dist/cli/commands/bulk.d.ts.map +1 -1
- package/dist/cli/commands/bulk.js +51 -33
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +26 -18
- package/dist/cli/commands/showReport.d.ts +1 -1
- package/dist/cli/commands/showReport.d.ts.map +1 -1
- package/dist/cli/commands/showReport.js +12 -10
- package/dist/cli/commands/status.d.ts +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +8 -8
- package/dist/cli/commands/sync.d.ts +1 -1
- package/dist/cli/commands/sync.d.ts.map +1 -1
- package/dist/cli/commands/sync.js +38 -30
- package/dist/cli/index.js +9 -9
- package/dist/cli/ui/DnaTable.d.ts +1 -1
- package/dist/cli/ui/DnaTable.d.ts.map +1 -1
- package/dist/cli/ui/DnaTable.js +23 -16
- package/dist/cli/ui/RefactorWizard.d.ts +3 -3
- package/dist/cli/ui/RefactorWizard.d.ts.map +1 -1
- package/dist/cli/ui/RefactorWizard.js +108 -82
- package/dist/config/ConfigLoader.d.ts +3 -2
- package/dist/config/ConfigLoader.d.ts.map +1 -1
- package/dist/config/ConfigLoader.js +23 -13
- package/dist/config/DryRunGuard.d.ts +14 -0
- package/dist/config/DryRunGuard.d.ts.map +1 -0
- package/dist/config/DryRunGuard.js +79 -0
- package/dist/config/SelaConfig.d.ts +26 -16
- package/dist/config/SelaConfig.d.ts.map +1 -1
- package/dist/config/SelaConfig.js +54 -22
- package/dist/engine/HealingRegistry.d.ts +1 -1
- package/dist/engine/HealingRegistry.js +3 -3
- package/dist/engine/SelaEngine.d.ts +13 -10
- package/dist/engine/SelaEngine.d.ts.map +1 -1
- package/dist/engine/SelaEngine.js +292 -101
- package/dist/errors/SelaError.d.ts +132 -0
- package/dist/errors/SelaError.d.ts.map +1 -0
- package/dist/errors/SelaError.js +154 -0
- package/dist/fixtures/expectProxy.d.ts +1 -1
- package/dist/fixtures/expectProxy.d.ts.map +1 -1
- package/dist/fixtures/expectProxy.js +17 -10
- package/dist/fixtures/index.d.ts +7 -1
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/index.js +26 -11
- package/dist/fixtures/proxyTag.d.ts +12 -0
- package/dist/fixtures/proxyTag.d.ts.map +1 -0
- package/dist/fixtures/proxyTag.js +45 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/reporter/SelaReporter.d.ts +143 -0
- package/dist/reporter/SelaReporter.d.ts.map +1 -0
- package/dist/reporter/SelaReporter.js +428 -0
- package/dist/services/ASTSourceUpdater.d.ts +20 -1
- package/dist/services/ASTSourceUpdater.d.ts.map +1 -1
- package/dist/services/ASTSourceUpdater.js +257 -89
- package/dist/services/ArgumentTypeAnalyzer.js +7 -7
- package/dist/services/BlastRadiusAnalyzer.d.ts.map +1 -1
- package/dist/services/BlastRadiusAnalyzer.js +5 -2
- package/dist/services/ChainValidator.js +22 -22
- package/dist/services/CrossFileHealer.js +7 -7
- package/dist/services/DefinitionTracer.d.ts +1 -1
- package/dist/services/DefinitionTracer.d.ts.map +1 -1
- package/dist/services/DefinitionTracer.js +28 -13
- package/dist/services/DnaEditorService.d.ts +2 -2
- package/dist/services/DnaEditorService.d.ts.map +1 -1
- package/dist/services/DnaEditorService.js +81 -62
- package/dist/services/DnaIndexService.d.ts +1 -1
- package/dist/services/DnaIndexService.d.ts.map +1 -1
- package/dist/services/DnaIndexService.js +14 -14
- package/dist/services/HealReportService.d.ts +43 -1
- package/dist/services/HealReportService.d.ts.map +1 -1
- package/dist/services/HealReportService.js +108 -25
- package/dist/services/HealingAdvisory.js +1 -1
- package/dist/services/HealingCacheService.d.ts +135 -0
- package/dist/services/HealingCacheService.d.ts.map +1 -0
- package/dist/services/HealingCacheService.js +460 -0
- package/dist/services/HealthReportService.d.ts +1 -1
- package/dist/services/HealthReportService.d.ts.map +1 -1
- package/dist/services/HealthReportService.js +25 -25
- package/dist/services/InitializerUpdater.d.ts.map +1 -1
- package/dist/services/InitializerUpdater.js +20 -1
- package/dist/services/IntentAuditor.d.ts +18 -1
- package/dist/services/IntentAuditor.d.ts.map +1 -1
- package/dist/services/IntentAuditor.js +50 -26
- package/dist/services/InteractiveReview.d.ts +15 -0
- package/dist/services/InteractiveReview.d.ts.map +1 -0
- package/dist/services/InteractiveReview.js +125 -0
- package/dist/services/LLMService.d.ts +18 -2
- package/dist/services/LLMService.d.ts.map +1 -1
- package/dist/services/LLMService.js +33 -23
- package/dist/services/PRAutomationService.d.ts +14 -1
- package/dist/services/PRAutomationService.d.ts.map +1 -1
- package/dist/services/PRAutomationService.js +287 -92
- package/dist/services/PendingPromptLedger.d.ts +44 -0
- package/dist/services/PendingPromptLedger.d.ts.map +1 -0
- package/dist/services/PendingPromptLedger.js +180 -0
- package/dist/services/ReportGenerator.d.ts +70 -0
- package/dist/services/ReportGenerator.d.ts.map +1 -0
- package/dist/services/ReportGenerator.js +191 -0
- package/dist/services/SafetyGuard.d.ts +30 -7
- package/dist/services/SafetyGuard.d.ts.map +1 -1
- package/dist/services/SafetyGuard.js +85 -116
- package/dist/services/SnapshotService.js +5 -5
- package/dist/services/SourceLinkService.d.ts +2 -2
- package/dist/services/SourceLinkService.d.ts.map +1 -1
- package/dist/services/SourceLinkService.js +31 -22
- package/dist/services/SourceUpdater.d.ts +1 -1
- package/dist/services/SourceUpdater.d.ts.map +1 -1
- package/dist/services/SourceUpdater.js +66 -34
- package/dist/services/TemplateDiffService.d.ts.map +1 -1
- package/dist/services/TemplateDiffService.js +18 -15
- package/dist/services/WorkspaceSnapshotService.d.ts +71 -0
- package/dist/services/WorkspaceSnapshotService.d.ts.map +1 -0
- package/dist/services/WorkspaceSnapshotService.js +200 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/utils/IsolatedDiff.d.ts +45 -0
- package/dist/utils/IsolatedDiff.d.ts.map +1 -0
- package/dist/utils/IsolatedDiff.js +382 -0
- 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
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -91,7 +93,7 @@ function isSelaHealBranch(branch) {
|
|
|
91
93
|
* Resolve the repo's default integration branch.
|
|
92
94
|
* 1. origin/HEAD symbolic-ref (set by `git clone`).
|
|
93
95
|
* 2. First existing local ref among: main, master, develop, dev.
|
|
94
|
-
* Returns null if none found
|
|
96
|
+
* Returns null if none found - caller must handle that.
|
|
95
97
|
*/
|
|
96
98
|
function findDefaultBranch(cwd) {
|
|
97
99
|
const headRef = sh("git symbolic-ref refs/remotes/origin/HEAD", cwd);
|
|
@@ -122,7 +124,7 @@ function detectBranch(cwd = process.cwd()) {
|
|
|
122
124
|
const ghRefName = process.env.GITHUB_REF_NAME; // current branch on push, or "<n>/merge" on PR
|
|
123
125
|
const ghEventName = process.env.GITHUB_EVENT_NAME;
|
|
124
126
|
const ghRef = process.env.GITHUB_REF; // "refs/pull/123/merge"
|
|
125
|
-
// Prefer HEAD_REF on PRs
|
|
127
|
+
// Prefer HEAD_REF on PRs - REF_NAME is the synthetic merge ref.
|
|
126
128
|
if (ghHeadRef && ghHeadRef.length > 0 && !isSelaHealBranch(ghHeadRef)) {
|
|
127
129
|
const prNumber = (() => {
|
|
128
130
|
const m = ghRef?.match(/refs\/pull\/(\d+)\//);
|
|
@@ -173,11 +175,11 @@ function detectBranch(cwd = process.cwd()) {
|
|
|
173
175
|
if (isSelaHealBranch(localBranch)) {
|
|
174
176
|
const fallback = findDefaultBranch(cwd);
|
|
175
177
|
if (fallback) {
|
|
176
|
-
console.warn(`[Sela PR] ⚠️ HEAD is on '${localBranch}' (sela patch branch)
|
|
178
|
+
console.warn(`[Sela PR] ⚠️ HEAD is on '${localBranch}' (sela patch branch) - ` +
|
|
177
179
|
`using '${fallback}' as PR base instead.`);
|
|
178
180
|
return { branch: fallback, isPR: false, prNumber: null, source: "git" };
|
|
179
181
|
}
|
|
180
|
-
console.warn(`[Sela PR] ⚠️ HEAD is on '${localBranch}' and no default branch found
|
|
182
|
+
console.warn(`[Sela PR] ⚠️ HEAD is on '${localBranch}' and no default branch found - ` +
|
|
181
183
|
`skipping PR automation to avoid self-targeting.`);
|
|
182
184
|
return { branch: null, isPR: false, prNumber: null, source: "none" };
|
|
183
185
|
}
|
|
@@ -188,7 +190,7 @@ function detectBranch(cwd = process.cwd()) {
|
|
|
188
190
|
// ═══════════════════════════════════════════════════════════════════
|
|
189
191
|
function decideEffectiveStrategy(cfg, branch, heals) {
|
|
190
192
|
// Compute MIN across only the heals that reported a finite numeric
|
|
191
|
-
// confidence. When *no* heal carried one, the result is `undefined`
|
|
193
|
+
// confidence. When *no* heal carried one, the result is `undefined` -
|
|
192
194
|
// surfaced as "n/a" in reports rather than synthesised as 100% (which
|
|
193
195
|
// would silently mask the absence of evidence) or 0% (which would
|
|
194
196
|
// incorrectly force a downgrade for every clean run on Sonnet).
|
|
@@ -217,7 +219,7 @@ function decideEffectiveStrategy(cfg, branch, heals) {
|
|
|
217
219
|
downgradeReason: "PROTECTED_BRANCH",
|
|
218
220
|
};
|
|
219
221
|
}
|
|
220
|
-
// Rule 2: low AI confidence
|
|
222
|
+
// Rule 2: low AI confidence - only triggers when the measured min is
|
|
221
223
|
// actually below the threshold. Missing confidence is "unknown", not
|
|
222
224
|
// "zero", so we do NOT downgrade purely on absence.
|
|
223
225
|
if (cfg.reviewThresholds.minConfidenceForDirectCommit > 0 &&
|
|
@@ -229,7 +231,7 @@ function decideEffectiveStrategy(cfg, branch, heals) {
|
|
|
229
231
|
downgradeReason: "LOW_CONFIDENCE",
|
|
230
232
|
};
|
|
231
233
|
}
|
|
232
|
-
// Rule 3: low auditor score
|
|
234
|
+
// Rule 3: low auditor score - same "absence ≠ zero" rule as Rule 2.
|
|
233
235
|
if (cfg.reviewThresholds.minAuditorScoreForDirectCommit > 0 &&
|
|
234
236
|
typeof minAud === "number" &&
|
|
235
237
|
minAud < cfg.reviewThresholds.minAuditorScoreForDirectCommit) {
|
|
@@ -283,7 +285,7 @@ function stripDiffPreamble(raw) {
|
|
|
283
285
|
}
|
|
284
286
|
function buildDefaultBody(heals, branchInfo, reportLink, decision) {
|
|
285
287
|
const lines = [];
|
|
286
|
-
lines.push(`# 🤖 Sela Insights
|
|
288
|
+
lines.push(`# 🤖 Sela Insights - Automation Suite Healed!`);
|
|
287
289
|
lines.push("");
|
|
288
290
|
lines.push(`Sela kept your pipeline running by healing ${heals.length} broken locator(s).`);
|
|
289
291
|
lines.push("");
|
|
@@ -299,12 +301,12 @@ function buildDefaultBody(heals, branchInfo, reportLink, decision) {
|
|
|
299
301
|
lines.push(`## 💻 Per-Heal Diff`);
|
|
300
302
|
for (const h of heals) {
|
|
301
303
|
const lineRef = h.newLineNumber ?? h.sourceLine;
|
|
302
|
-
lines.push(`### \`${h.sourceFile}:${lineRef}\`
|
|
304
|
+
lines.push(`### \`${h.sourceFile}:${lineRef}\` - ${h.testTitle ?? "(no test title)"}`);
|
|
303
305
|
lines.push("");
|
|
304
306
|
lines.push("```diff");
|
|
305
307
|
if (h.gitUnifiedDiff && h.gitUnifiedDiff.trim().length > 0) {
|
|
306
308
|
// Emit the real git diff verbatim. This natively handles cross-file
|
|
307
|
-
// healing, multi-line edits, and template-literal changes
|
|
309
|
+
// healing, multi-line edits, and template-literal changes - none of
|
|
308
310
|
// which the legacy single-line -/+ pair could represent.
|
|
309
311
|
lines.push(stripDiffPreamble(h.gitUnifiedDiff));
|
|
310
312
|
}
|
|
@@ -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
|
-
|
|
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,
|
|
@@ -335,34 +349,33 @@ async function execute(cfg, decision, heals, branchInfo, ctx) {
|
|
|
335
349
|
errors: [],
|
|
336
350
|
};
|
|
337
351
|
if (decision.effective === "readOnly") {
|
|
338
|
-
console.log(`[Sela PR] 🛡️ readOnly
|
|
352
|
+
console.log(`[Sela PR] 🛡️ readOnly - no git operations performed.`);
|
|
339
353
|
if (ctx.reportHtmlPath) {
|
|
340
354
|
console.log(`[Sela PR] Report artifact: ${ctx.reportHtmlPath}`);
|
|
341
355
|
}
|
|
342
356
|
return result;
|
|
343
357
|
}
|
|
344
358
|
if (heals.length === 0) {
|
|
345
|
-
console.log(`[Sela PR] No HEALED events
|
|
359
|
+
console.log(`[Sela PR] No HEALED events - skipping git operations.`);
|
|
346
360
|
return result;
|
|
347
361
|
}
|
|
348
362
|
if (!gitAvailable(ctx.cwd)) {
|
|
349
363
|
result.errors.push("git CLI not available");
|
|
350
|
-
console.warn(`[Sela PR] ⚠️ git CLI not available
|
|
364
|
+
console.warn(`[Sela PR] ⚠️ git CLI not available - skipping.`);
|
|
351
365
|
return result;
|
|
352
366
|
}
|
|
353
367
|
if (isDetachedHead(ctx.cwd)) {
|
|
354
368
|
result.errors.push("detached HEAD");
|
|
355
|
-
console.warn(`[Sela PR] ⚠️ Detached HEAD
|
|
369
|
+
console.warn(`[Sela PR] ⚠️ Detached HEAD - skipping git operations.`);
|
|
356
370
|
return result;
|
|
357
371
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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,108 +394,288 @@ 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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
console.warn(`[Sela PR] ⚠️ gh CLI not installed
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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 ${
|
|
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
|
-
|
|
430
|
-
console.warn(`[Sela PR] ⚠️ git commit failed: ${commit.stderr}`);
|
|
431
|
-
return result;
|
|
485
|
+
throw new Error(`git commit: ${commit.stderr}`);
|
|
432
486
|
}
|
|
433
|
-
|
|
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
|
-
|
|
436
|
-
console.warn(`[Sela PR] ⚠️ git push failed: ${push.stderr}`);
|
|
437
|
-
return result;
|
|
494
|
+
throw new Error(`git push: ${push.stderr}`);
|
|
438
495
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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 {
|
|
457
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
569
|
+
? `--base ${shellQuote(branchInfo.branch)}`
|
|
570
|
+
: "";
|
|
571
|
+
const prCmd = `gh pr create ${draftFlag} ${baseFlag} ` +
|
|
572
|
+
`--title ${shellQuote(title)} ` +
|
|
573
|
+
`--body-file ${shellQuote(bodyFile)} ` +
|
|
574
|
+
`${labelFlag}`;
|
|
575
|
+
const pr = sh(prCmd, ctx.cwd);
|
|
576
|
+
try {
|
|
577
|
+
fs.unlinkSync(bodyFile);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
/* ignore */
|
|
581
|
+
}
|
|
582
|
+
if (!pr.ok) {
|
|
583
|
+
result.errors.push(`gh pr create: ${pr.stderr}`);
|
|
584
|
+
console.warn(`[Sela PR] ⚠️ gh pr create failed: ${pr.stderr}`);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const url = pr.stdout.trim();
|
|
588
|
+
console.log(`[Sela PR] ✅ ${mode === "draftPR" ? "Draft PR" : "PR"} opened: ${url}`);
|
|
589
|
+
return url;
|
|
590
|
+
}
|
|
591
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
592
|
+
// CLEAN-ROOM HELPERS
|
|
593
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
594
|
+
function currentBranch(cwd) {
|
|
595
|
+
const r = sh("git rev-parse --abbrev-ref HEAD", cwd);
|
|
596
|
+
if (!r.ok)
|
|
597
|
+
return null;
|
|
598
|
+
const b = r.stdout.trim();
|
|
599
|
+
return b && b !== "HEAD" ? b : null;
|
|
600
|
+
}
|
|
601
|
+
function workspaceRestoreOriginal(snaps) {
|
|
602
|
+
for (const snap of snaps) {
|
|
603
|
+
try {
|
|
604
|
+
if (snap.createdBySela) {
|
|
605
|
+
if (fs.existsSync(snap.absolutePath))
|
|
606
|
+
fs.unlinkSync(snap.absolutePath);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
fs.writeFileSync(snap.absolutePath, snap.contentBefore, "utf8");
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
console.warn(`[Sela PR] ⚠️ Could not restore ${snap.relativePath}: ${err?.message ?? err}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function applyMutationToClean(snap) {
|
|
617
|
+
const after = snap.contentAfter ?? "";
|
|
618
|
+
// File didn't exist on HEAD (created by Sela or untracked). Write the
|
|
619
|
+
// post-mutation content directly - there's no clean base to merge into.
|
|
620
|
+
if (snap.headContent === null) {
|
|
621
|
+
return { ok: true, result: after };
|
|
622
|
+
}
|
|
623
|
+
// No dirty edits - `headContent === contentBefore`, so the mutation
|
|
624
|
+
// applies cleanly as a 1:1 string equivalence.
|
|
625
|
+
if (snap.headContent === snap.contentBefore) {
|
|
626
|
+
return { ok: true, result: after };
|
|
627
|
+
}
|
|
628
|
+
// Dirty edits present - extract Sela's specific change blocks and
|
|
629
|
+
// re-apply them against the clean HEAD content via unique substring
|
|
630
|
+
// replacement. Fails loudly when the dirty edits overlap Sela's site.
|
|
631
|
+
const blocks = (0, IsolatedDiff_1.extractChangeBlocks)(snap.contentBefore, after);
|
|
632
|
+
if (blocks.length === 0) {
|
|
633
|
+
return { ok: true, result: snap.headContent };
|
|
634
|
+
}
|
|
635
|
+
return (0, IsolatedDiff_1.applyChangeBlocks)(snap.headContent, blocks);
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* CI adapters (GitHub Actions, GitLab, Jenkins) often run with no global
|
|
639
|
+
* git identity. `git commit` then fails with `Please tell me who you are`,
|
|
640
|
+
* tanking the whole heal pipeline. Plant a sela bot identity for THIS
|
|
641
|
+
* commit only when no `user.email` / `user.name` is configured - never
|
|
642
|
+
* overwrite an existing identity.
|
|
643
|
+
*/
|
|
644
|
+
function configureGitIdentityForCI(cwd) {
|
|
645
|
+
const isCI = process.env.CI === "true" ||
|
|
646
|
+
process.env.CI === "1" ||
|
|
647
|
+
!!process.env.GITHUB_ACTIONS ||
|
|
648
|
+
!!process.env.GITLAB_CI ||
|
|
649
|
+
!!process.env.JENKINS_URL;
|
|
650
|
+
if (!isCI)
|
|
651
|
+
return;
|
|
652
|
+
const email = sh("git config user.email", cwd);
|
|
653
|
+
if (!email.ok || email.stdout.trim().length === 0) {
|
|
654
|
+
sh(`git config user.email "sela-bot@anthropic.local"`, cwd);
|
|
655
|
+
}
|
|
656
|
+
const name = sh("git config user.name", cwd);
|
|
657
|
+
if (!name.ok || name.stdout.trim().length === 0) {
|
|
658
|
+
sh(`git config user.name "Sela Bot"`, cwd);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
468
661
|
// ═══════════════════════════════════════════════════════════════════
|
|
469
662
|
// BUG-DETECTED HANDLING
|
|
470
663
|
// ═══════════════════════════════════════════════════════════════════
|
|
471
664
|
async function handleBugDetected(action, events, branchInfo, ctx) {
|
|
472
665
|
if (events.length === 0)
|
|
473
666
|
return;
|
|
474
|
-
console.warn(`\n[Sela Protection] ⚠️ ${events.length} potential regression(s) detected
|
|
667
|
+
console.warn(`\n[Sela Protection] ⚠️ ${events.length} potential regression(s) detected - auto-fix blocked.`);
|
|
475
668
|
for (const e of events) {
|
|
476
|
-
console.warn(` • ${e.sourceFile}:${e.sourceLine}
|
|
669
|
+
console.warn(` • ${e.sourceFile}:${e.sourceLine} - ${e.reason} (auditor: ${e.auditor?.verdict ?? "n/a"})`);
|
|
477
670
|
}
|
|
478
671
|
if (action === "failCI") {
|
|
479
|
-
console.warn(`[Sela Protection] 🔴 onBugDetected='failCI'
|
|
672
|
+
console.warn(`[Sela Protection] 🔴 onBugDetected='failCI' - setting process.exitCode = 1`);
|
|
480
673
|
process.exitCode = 1;
|
|
481
674
|
return;
|
|
482
675
|
}
|
|
483
676
|
if (action === "createIssue") {
|
|
484
677
|
if (!ghAvailable(ctx.cwd)) {
|
|
485
|
-
console.warn(`[Sela Protection] ⚠️ gh CLI unavailable
|
|
678
|
+
console.warn(`[Sela Protection] ⚠️ gh CLI unavailable - cannot create issue.`);
|
|
486
679
|
return;
|
|
487
680
|
}
|
|
488
681
|
for (const e of events) {
|
|
@@ -515,7 +708,9 @@ async function handleBugDetected(action, events, branchInfo, ctx) {
|
|
|
515
708
|
try {
|
|
516
709
|
fs.unlinkSync(bodyFile);
|
|
517
710
|
}
|
|
518
|
-
catch {
|
|
711
|
+
catch {
|
|
712
|
+
/* ignore */
|
|
713
|
+
}
|
|
519
714
|
if (!r.ok) {
|
|
520
715
|
console.warn(`[Sela Protection] ⚠️ gh issue create failed: ${r.stderr}`);
|
|
521
716
|
}
|
|
@@ -527,11 +722,11 @@ async function handleBugDetected(action, events, branchInfo, ctx) {
|
|
|
527
722
|
}
|
|
528
723
|
if (action === "labelOnly") {
|
|
529
724
|
if (!ghAvailable(ctx.cwd)) {
|
|
530
|
-
console.warn(`[Sela Protection] ⚠️ gh CLI unavailable
|
|
725
|
+
console.warn(`[Sela Protection] ⚠️ gh CLI unavailable - cannot label PR.`);
|
|
531
726
|
return;
|
|
532
727
|
}
|
|
533
728
|
if (!branchInfo.isPR || !branchInfo.prNumber) {
|
|
534
|
-
console.warn(`[Sela Protection] ⚠️ Not running on a PR
|
|
729
|
+
console.warn(`[Sela Protection] ⚠️ Not running on a PR - labelOnly skipped.`);
|
|
535
730
|
return;
|
|
536
731
|
}
|
|
537
732
|
const labelCmd = `gh pr edit ${branchInfo.prNumber} --add-label sela-bug-detected`;
|
|
@@ -551,7 +746,7 @@ async function handleBugDetected(action, events, branchInfo, ctx) {
|
|
|
551
746
|
}
|
|
552
747
|
}
|
|
553
748
|
// ═══════════════════════════════════════════════════════════════════
|
|
554
|
-
// SHELL QUOTING
|
|
749
|
+
// SHELL QUOTING - cross-platform (POSIX single-quote escape;
|
|
555
750
|
// on Windows execSync uses cmd.exe which handles double-quotes natively).
|
|
556
751
|
// ═══════════════════════════════════════════════════════════════════
|
|
557
752
|
function shellQuote(s) {
|
|
@@ -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"}
|