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
@@ -0,0 +1,460 @@
1
+ "use strict";
2
+ // src/services/HealingCacheService.ts
3
+ //
4
+ // Local healing cache — short-circuits the LLM round-trip when an
5
+ // identical heal-context has been resolved successfully in a prior
6
+ // session.
7
+ //
8
+ // Cache key (matches DX MVP Phase 6 plan):
9
+ // SHA1(filePath + ":" + line + "::" + dnaContentHash + "::" + brokenSelector + "::" + mode)
10
+ // Including `dnaContentHash` means a DNA rewrite automatically
11
+ // invalidates every entry keyed against that fingerprint — old
12
+ // entries simply stop matching and age out via TTL.
13
+ //
14
+ // Cache value:
15
+ // `{ chainSegments, segments, confidence, contentChange, explanation,
16
+ // alternatives, safe, createdAt, ttlExpiresAt, hitCount, lastHitAt }`.
17
+ // TTL defaults to 7 days; overridable via `SELA_CACHE_TTL_DAYS`.
18
+ //
19
+ // Storage:
20
+ // `.sela-cache.json` in process.cwd() by default (configurable via the
21
+ // constructor for tests). Single-file map of SHA1 keys → entry.
22
+ // Schema-versioned (`schemaVersion: 1`).
23
+ //
24
+ // Concurrency model (three layers, identical to the plan):
25
+ //
26
+ // 1. In-process mutex
27
+ // `inFlight: Promise<void>` chain. Concurrent setters in the SAME
28
+ // Node process queue behind one another via the chained Promise.
29
+ // Any rejection on the chain is swallowed before the next call
30
+ // awaits it, so a single bad write CANNOT deadlock subsequent
31
+ // callers.
32
+ //
33
+ // 2. Cross-process file lock
34
+ // Playwright workers run as separate processes — atomic
35
+ // `mkdir <cacheDir>/.sela-cache.lock` is used as the mutex. Second
36
+ // process gets `EEXIST`, polls with exponential backoff
37
+ // (50ms → 800ms, max 6 retries ≈ 3.15 s ceiling). Lock is held
38
+ // only across the read-modify-write window, never during reads.
39
+ //
40
+ // 3. Atomic write via temp + rename
41
+ // Write to `.sela-cache.json.tmp`, copy the previous file to
42
+ // `.sela-cache.json.bak`, then `fs.renameSync(tmp, target)` —
43
+ // atomic on POSIX and on Windows (Node ≥14) when source and
44
+ // target share a volume (always true here).
45
+ //
46
+ // Read path:
47
+ // memory check → file check (no lock) → on miss the caller (SelaEngine)
48
+ // acquires the LLM via the normal path; cache writes happen post-success.
49
+ //
50
+ // `get()` is strictly read-only on disk: hit-count / lastHitAt are
51
+ // bumped IN-MEMORY only — they never trigger a disk write. This avoids
52
+ // the documented bug where concurrent worker `get()` calls would
53
+ // overwrite valid `set()` operations from peer workers.
54
+ //
55
+ // Failure tolerance: any read failure (missing, malformed JSON, wrong
56
+ // schema, EACCES, …) returns "no entries" instead of throwing. The
57
+ // cache is a latency optimisation — never a hard dependency of the
58
+ // heal pipeline.
59
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
60
+ if (k2 === undefined) k2 = k;
61
+ var desc = Object.getOwnPropertyDescriptor(m, k);
62
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
63
+ desc = { enumerable: true, get: function() { return m[k]; } };
64
+ }
65
+ Object.defineProperty(o, k2, desc);
66
+ }) : (function(o, m, k, k2) {
67
+ if (k2 === undefined) k2 = k;
68
+ o[k2] = m[k];
69
+ }));
70
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
71
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
72
+ }) : function(o, v) {
73
+ o["default"] = v;
74
+ });
75
+ var __importStar = (this && this.__importStar) || (function () {
76
+ var ownKeys = function(o) {
77
+ ownKeys = Object.getOwnPropertyNames || function (o) {
78
+ var ar = [];
79
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
80
+ return ar;
81
+ };
82
+ return ownKeys(o);
83
+ };
84
+ return function (mod) {
85
+ if (mod && mod.__esModule) return mod;
86
+ var result = {};
87
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
88
+ __setModuleDefault(result, mod);
89
+ return result;
90
+ };
91
+ })();
92
+ Object.defineProperty(exports, "__esModule", { value: true });
93
+ exports.sharedHealingCache = exports.HealingCacheService = void 0;
94
+ exports.computeCacheKey = computeCacheKey;
95
+ exports.hashDnaContent = hashDnaContent;
96
+ const fs = __importStar(require("fs"));
97
+ const path = __importStar(require("path"));
98
+ const crypto_1 = require("crypto");
99
+ const DryRunGuard_1 = require("../config/DryRunGuard");
100
+ // ═══════════════════════════════════════════════════════════════════
101
+ // KEY HASH
102
+ // ═══════════════════════════════════════════════════════════════════
103
+ const KEY_SEPARATOR = "::";
104
+ const DEFAULT_TTL_DAYS = 7;
105
+ function defaultTtlMs() {
106
+ const raw = process.env.SELA_CACHE_TTL_DAYS;
107
+ if (!raw)
108
+ return DEFAULT_TTL_DAYS * 24 * 60 * 60 * 1000;
109
+ const n = Number(raw);
110
+ if (!Number.isFinite(n) || n <= 0) {
111
+ return DEFAULT_TTL_DAYS * 24 * 60 * 60 * 1000;
112
+ }
113
+ return n * 24 * 60 * 60 * 1000;
114
+ }
115
+ function computeCacheKey(input) {
116
+ // Normalise filePath separators so a cache entry written from a Windows
117
+ // worker is still found from a POSIX worker hitting the same repo.
118
+ const normPath = path.resolve(input.filePath).split(path.sep).join("/");
119
+ return (0, crypto_1.createHash)("sha1")
120
+ .update([
121
+ normPath + ":" + input.line,
122
+ input.dnaContentHash,
123
+ input.brokenSelector,
124
+ input.mode,
125
+ ].join(KEY_SEPARATOR))
126
+ .digest("hex");
127
+ }
128
+ /**
129
+ * Convenience helper — fingerprint the JSON form of a DNA snapshot so
130
+ * the cache key changes whenever the DNA is rewritten.
131
+ */
132
+ function hashDnaContent(dna) {
133
+ try {
134
+ const json = typeof dna === "string" ? dna : JSON.stringify(dna ?? {});
135
+ return (0, crypto_1.createHash)("sha1").update(json).digest("hex");
136
+ }
137
+ catch {
138
+ return "no-dna";
139
+ }
140
+ }
141
+ // ═══════════════════════════════════════════════════════════════════
142
+ // HealingCacheService
143
+ // ═══════════════════════════════════════════════════════════════════
144
+ class HealingCacheService {
145
+ cacheDir;
146
+ filename;
147
+ lockDir;
148
+ tmpSuffix = ".tmp";
149
+ bakSuffix = ".bak";
150
+ // In-process serialisation. Every new set() chains onto the prior
151
+ // tail Promise. The chain is fault-tolerant — see `set()` below.
152
+ inFlight = Promise.resolve();
153
+ constructor(opts) {
154
+ this.cacheDir = opts?.cacheDir ?? process.cwd();
155
+ this.filename = opts?.filename ?? ".sela-cache.json";
156
+ this.lockDir = path.join(this.cacheDir, ".sela-cache.lock");
157
+ }
158
+ // ─────────────────────────────────────────────────────────────
159
+ // PUBLIC API
160
+ // ─────────────────────────────────────────────────────────────
161
+ /**
162
+ * Read-only lookup. Bumps `hitCount` / `lastHitAt` IN-MEMORY only on
163
+ * the returned entry reference — never persisted on the get() path.
164
+ * This eliminates the read-write race where parallel workers each
165
+ * call get() and serialise full entries-map snapshots back to disk,
166
+ * overwriting peer set() operations.
167
+ */
168
+ get(input) {
169
+ const key = computeCacheKey(input);
170
+ const file = this.safeRead();
171
+ const entry = file.entries[key];
172
+ if (!entry)
173
+ return null;
174
+ if (!entry.safe)
175
+ return null;
176
+ if (this.isExpired(entry))
177
+ return null;
178
+ // In-memory only — DO NOT write back. Hit-count drift across workers
179
+ // is acceptable; corrupting peer writes is not.
180
+ entry.hitCount = (entry.hitCount ?? 0) + 1;
181
+ entry.lastHitAt = new Date().toISOString();
182
+ return entry;
183
+ }
184
+ /**
185
+ * Persist a new cache entry. Serialises against in-process writers
186
+ * AND across processes via the mkdir lock. The in-process chain
187
+ * isolates each `await` so a rejected upstream Promise never
188
+ * deadlocks downstream callers.
189
+ */
190
+ async set(input) {
191
+ if (DryRunGuard_1.DryRunGuard.active()) {
192
+ // Don't pollute the cache during a SELA_DRY_RUN session.
193
+ return;
194
+ }
195
+ // Chain onto the previous tail, but swallow rejections via a
196
+ // `.catch(noop)` adapter. Without this, an earlier rejected set()
197
+ // would propagate through every subsequent `await prev` and either
198
+ // throw at the wrong caller or — worse — leak through the finally
199
+ // block without releasing the queue. The wrapper guarantees the
200
+ // queue is FIFO and rejection-safe.
201
+ const prev = this.inFlight.catch(() => {
202
+ /* swallow — upstream failure handled by its own caller */
203
+ });
204
+ let release;
205
+ const myTurn = new Promise((res) => {
206
+ release = res;
207
+ });
208
+ this.inFlight = myTurn;
209
+ try {
210
+ await prev;
211
+ await this.lockedWrite(input);
212
+ }
213
+ finally {
214
+ // Always release — even on throw — so the next queued caller
215
+ // unblocks. Errors still propagate up to *this* caller normally.
216
+ release();
217
+ }
218
+ }
219
+ /** Synchronous variant — used by teardown / CLI paths. */
220
+ setSync(input) {
221
+ if (DryRunGuard_1.DryRunGuard.active())
222
+ return;
223
+ this.withFileLockSync(() => {
224
+ const file = this.safeRead();
225
+ const key = computeCacheKey(input);
226
+ file.entries[key] = this.buildEntry(key, input, file.entries[key]);
227
+ this.atomicWrite(file);
228
+ });
229
+ }
230
+ /** Wipe every entry. Used by tests and the (forthcoming) `sela cache clear` CLI. */
231
+ clear() {
232
+ this.withFileLockSync(() => {
233
+ const target = path.join(this.cacheDir, this.filename);
234
+ const bak = target + this.bakSuffix;
235
+ const tmp = target + this.tmpSuffix;
236
+ for (const f of [target, bak, tmp]) {
237
+ try {
238
+ if (fs.existsSync(f))
239
+ fs.unlinkSync(f);
240
+ }
241
+ catch {
242
+ // best-effort
243
+ }
244
+ }
245
+ });
246
+ }
247
+ /** Returns the parsed cache file (or an empty shell on failure). */
248
+ read() {
249
+ return this.safeRead();
250
+ }
251
+ /** Absolute path of the on-disk cache file. */
252
+ filePath() {
253
+ return path.join(this.cacheDir, this.filename);
254
+ }
255
+ /**
256
+ * Prunes expired entries. Cheap O(n) scan — intended for engine
257
+ * startup. Returns the number of entries removed.
258
+ */
259
+ pruneExpired() {
260
+ let removed = 0;
261
+ this.withFileLockSync(() => {
262
+ const file = this.safeRead();
263
+ for (const [k, e] of Object.entries(file.entries)) {
264
+ if (this.isExpired(e)) {
265
+ delete file.entries[k];
266
+ removed++;
267
+ }
268
+ }
269
+ if (removed > 0)
270
+ this.atomicWrite(file);
271
+ });
272
+ return removed;
273
+ }
274
+ // ─────────────────────────────────────────────────────────────
275
+ // INTERNAL — read / write / lock
276
+ // ─────────────────────────────────────────────────────────────
277
+ isExpired(entry) {
278
+ if (!entry.ttlExpiresAt)
279
+ return false;
280
+ const t = Date.parse(entry.ttlExpiresAt);
281
+ if (!Number.isFinite(t))
282
+ return false;
283
+ return t <= Date.now();
284
+ }
285
+ async lockedWrite(input) {
286
+ const acquired = await this.acquireLockWithBackoff();
287
+ if (!acquired) {
288
+ console.warn(`[HealingCache] ⚠️ Could not acquire lock at ${this.lockDir} — write skipped`);
289
+ return;
290
+ }
291
+ try {
292
+ const file = this.safeRead();
293
+ const key = computeCacheKey(input);
294
+ file.entries[key] = this.buildEntry(key, input, file.entries[key]);
295
+ this.atomicWrite(file);
296
+ }
297
+ finally {
298
+ this.releaseLock();
299
+ }
300
+ }
301
+ buildEntry(key, input, prev) {
302
+ const now = new Date().toISOString();
303
+ const ttlMs = input.ttlMs ?? defaultTtlMs();
304
+ const ttlExpiresAt = new Date(Date.now() + ttlMs).toISOString();
305
+ return {
306
+ key,
307
+ filePath: input.filePath,
308
+ line: input.line,
309
+ dnaContentHash: input.dnaContentHash,
310
+ brokenSelector: input.brokenSelector,
311
+ mode: input.mode,
312
+ healedSelector: input.healedSelector,
313
+ chainSegments: input.chainSegments,
314
+ segments: input.segments,
315
+ confidence: input.confidence,
316
+ contentChange: input.contentChange,
317
+ explanation: input.explanation,
318
+ alternatives: input.alternatives,
319
+ safe: input.safe,
320
+ createdAt: prev?.createdAt ?? now,
321
+ ttlExpiresAt,
322
+ lastHitAt: prev?.lastHitAt,
323
+ hitCount: prev?.hitCount ?? 0,
324
+ };
325
+ }
326
+ /**
327
+ * Reads + parses the cache file. Tolerates every realistic failure
328
+ * (missing, empty, malformed, wrong schema version) by returning a
329
+ * fresh empty shell — the contract is "cache misses are silent".
330
+ */
331
+ safeRead() {
332
+ const target = path.join(this.cacheDir, this.filename);
333
+ let raw;
334
+ try {
335
+ raw = fs.readFileSync(target, "utf8");
336
+ }
337
+ catch {
338
+ return { schemaVersion: 1, entries: {} };
339
+ }
340
+ if (!raw || raw.trim().length === 0) {
341
+ return { schemaVersion: 1, entries: {} };
342
+ }
343
+ let parsed;
344
+ try {
345
+ parsed = JSON.parse(raw);
346
+ }
347
+ catch (err) {
348
+ console.warn(`[HealingCache] ⚠️ Corrupt cache file at ${target} — starting fresh (${err.message})`);
349
+ return { schemaVersion: 1, entries: {} };
350
+ }
351
+ if (!parsed ||
352
+ typeof parsed !== "object" ||
353
+ Array.isArray(parsed) ||
354
+ parsed.schemaVersion !== 1 ||
355
+ typeof parsed.entries !== "object" ||
356
+ parsed.entries === null ||
357
+ Array.isArray(parsed.entries)) {
358
+ console.warn(`[HealingCache] ⚠️ Unknown cache schema at ${target} — starting fresh`);
359
+ return { schemaVersion: 1, entries: {} };
360
+ }
361
+ return parsed;
362
+ }
363
+ /**
364
+ * Atomic write: write to `<file>.tmp`, copy current to `<file>.bak`,
365
+ * then rename `<file>.tmp` → `<file>`. The rename is the single
366
+ * commit point — a crash between bak and rename leaves the bak file
367
+ * intact for future recovery.
368
+ */
369
+ atomicWrite(file) {
370
+ if (!fs.existsSync(this.cacheDir)) {
371
+ fs.mkdirSync(this.cacheDir, { recursive: true });
372
+ }
373
+ const target = path.join(this.cacheDir, this.filename);
374
+ const tmp = target + this.tmpSuffix;
375
+ const bak = target + this.bakSuffix;
376
+ const json = JSON.stringify(file, null, 2);
377
+ fs.writeFileSync(tmp, json, "utf8");
378
+ if (fs.existsSync(target)) {
379
+ try {
380
+ fs.copyFileSync(target, bak);
381
+ }
382
+ catch (err) {
383
+ console.warn(`[HealingCache] ⚠️ Failed to write backup ${bak}: ${err.message}`);
384
+ }
385
+ }
386
+ fs.renameSync(tmp, target);
387
+ }
388
+ // ─────────────────────────────────────────────────────────────
389
+ // FILE LOCK — atomic mkdir as a cross-process mutex
390
+ // ─────────────────────────────────────────────────────────────
391
+ async acquireLockWithBackoff() {
392
+ const delaysMs = [50, 100, 200, 400, 800, 800];
393
+ for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
394
+ if (this.tryAcquireLock())
395
+ return true;
396
+ if (attempt === delaysMs.length)
397
+ break;
398
+ await sleep(delaysMs[attempt]);
399
+ }
400
+ return false;
401
+ }
402
+ tryAcquireLock() {
403
+ if (!fs.existsSync(this.cacheDir)) {
404
+ fs.mkdirSync(this.cacheDir, { recursive: true });
405
+ }
406
+ try {
407
+ fs.mkdirSync(this.lockDir);
408
+ return true;
409
+ }
410
+ catch (err) {
411
+ if (err.code === "EEXIST")
412
+ return false;
413
+ console.warn(`[HealingCache] ⚠️ Lock acquire failed: ${err.message}`);
414
+ return false;
415
+ }
416
+ }
417
+ withFileLockSync(fn) {
418
+ const delaysMs = [10, 25, 50, 100];
419
+ for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
420
+ if (this.tryAcquireLock()) {
421
+ try {
422
+ fn();
423
+ }
424
+ finally {
425
+ this.releaseLock();
426
+ }
427
+ return;
428
+ }
429
+ if (attempt === delaysMs.length)
430
+ break;
431
+ sleepBusy(delaysMs[attempt]);
432
+ }
433
+ console.warn(`[HealingCache] ⚠️ Lock acquire failed (sync) — operation skipped`);
434
+ }
435
+ releaseLock() {
436
+ try {
437
+ fs.rmdirSync(this.lockDir);
438
+ }
439
+ catch {
440
+ // best-effort
441
+ }
442
+ }
443
+ }
444
+ exports.HealingCacheService = HealingCacheService;
445
+ // ═══════════════════════════════════════════════════════════════════
446
+ // Utilities
447
+ // ═══════════════════════════════════════════════════════════════════
448
+ function sleep(ms) {
449
+ return new Promise((res) => setTimeout(res, ms));
450
+ }
451
+ function sleepBusy(ms) {
452
+ const end = Date.now() + ms;
453
+ while (Date.now() < end) {
454
+ // intentional spin
455
+ }
456
+ }
457
+ // ═══════════════════════════════════════════════════════════════════
458
+ // Module-level singleton — engines reuse the same cache file by default
459
+ // ═══════════════════════════════════════════════════════════════════
460
+ exports.sharedHealingCache = new HealingCacheService();
@@ -1 +1 @@
1
- {"version":3,"file":"InitializerUpdater.d.ts","sourceRoot":"","sources":["../../src/services/InitializerUpdater.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,UAAU,EAAiB,MAAM,UAAU,CAAC;AAY3D,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,qBAAa,kBAAkB;IAC7B;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,CACV,SAAS,EAAE,IAAI,EACf,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,MAAM,GACf,iBAAiB,GAAG,IAAI;CAqB5B"}
1
+ {"version":3,"file":"InitializerUpdater.d.ts","sourceRoot":"","sources":["../../src/services/InitializerUpdater.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,UAAU,EAAiB,MAAM,UAAU,CAAC;AAc3D,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,qBAAa,kBAAkB;IAC7B;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,CACV,SAAS,EAAE,IAAI,EACf,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,MAAM,GACf,iBAAiB,GAAG,IAAI;CAsC5B"}
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.InitializerUpdater = void 0;
4
4
  const ts_morph_1 = require("ts-morph");
5
5
  const TemplateDiffService_js_1 = require("./TemplateDiffService.js");
6
+ const DryRunGuard_1 = require("../config/DryRunGuard");
7
+ const WorkspaceSnapshotService_1 = require("./WorkspaceSnapshotService");
6
8
  class InitializerUpdater {
7
9
  /**
8
10
  * Mutate a StringLiteral or NoSubstitutionTemplateLiteral node to
@@ -22,7 +24,24 @@ class InitializerUpdater {
22
24
  else {
23
25
  return null;
24
26
  }
25
- sourceFile.saveSync();
27
+ // Centralised write gate — mirrors ASTSourceUpdater._persistChanges.
28
+ // InitializerUpdater is the canonical write-head for definition-site
29
+ // mutations (SemanticDispatch → tracer-resolved identifier / property
30
+ // access / cross-file paths all converge here), so the Clean-Room
31
+ // snapshot ledger and DryRunGuard MUST be enforced here too.
32
+ // Without this, an upstream-traced fix (e.g. global StringLiteral at
33
+ // top of file) writes to disk but `WorkspaceSnapshotService` never
34
+ // sees it — `getMutatedSnapshots()` returns empty and PR automation
35
+ // skips the heal entirely.
36
+ const fp = sourceFile.getFilePath();
37
+ if (DryRunGuard_1.DryRunGuard.active()) {
38
+ DryRunGuard_1.DryRunGuard.logSkippedWrite("saveSync", fp);
39
+ }
40
+ else {
41
+ WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.preWrite(fp);
42
+ sourceFile.saveSync();
43
+ WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.postWrite(fp);
44
+ }
26
45
  return {
27
46
  lineUpdated: valueNode.getStartLineNumber() - 1,
28
47
  healedLocatorString: newValue,
@@ -1,3 +1,4 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
1
2
  import { AncestorNode, AnchorInfo } from "../types";
2
3
  export interface AuditRequest {
3
4
  dnaText: string;
@@ -27,10 +28,26 @@ export interface AuditVerdict {
27
28
  bonus: number;
28
29
  };
29
30
  }
31
+ /**
32
+ * Constructor options for IntentAuditor.
33
+ *
34
+ * - `anthropic`: pre-built Anthropic client to use. Pass `null` explicitly
35
+ * to force the auditor into its disabled state (useful in tests that
36
+ * want to assert the SUSPICIOUS fallback path without touching env vars).
37
+ * Pass `undefined` (default) to fall back to env-based construction.
38
+ * - `apiKey`: override the ANTHROPIC_API_KEY env var (DI-friendly).
39
+ * - `model`: override the Haiku model id (lets tests pin the SDK call).
40
+ */
41
+ export interface IntentAuditorOptions {
42
+ anthropic?: Anthropic | null;
43
+ apiKey?: string;
44
+ model?: string;
45
+ }
30
46
  export declare class IntentAuditor {
31
47
  private anthropic;
32
48
  private readonly disabled;
33
- constructor();
49
+ private readonly model;
50
+ constructor(opts?: IntentAuditorOptions);
34
51
  auditIntent(req: AuditRequest): Promise<AuditVerdict>;
35
52
  computeAncestryPenalty(dnaPath: AncestorNode[], livePath: AncestorNode[]): number;
36
53
  computeAnchorBonus(dnaAnchors: AnchorInfo | null | undefined, liveAnchors: AnchorInfo | null | undefined): number;
@@ -1 +1 @@
1
- {"version":3,"file":"IntentAuditor.d.ts","sourceRoot":"","sources":["../../src/services/IntentAuditor.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAOpD,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,WAAW,GAAG,QAAQ,CAAC;IAEjC,8EAA8E;IAC9E,WAAW,CAAC,EAAE,YAAY,EAAE,CAAC;IAC7B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,YAAY,EAAE,CAAC;IAC9B,+CAA+C;IAC/C,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,qEAAqE;IACrE,WAAW,CAAC,EAAE,UAAU,CAAC;CAC1B;AAED,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG,cAAc,GAAG,YAAY,CAAC;AAE7E,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,iBAAiB,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,UAAU,GAAG,cAAc,GAAG,OAAO,GAAG,UAAU,GAAG,IAAI,CAAC;IACzE,yEAAyE;IACzE,cAAc,CAAC,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5E;AAkED,qBAAa,aAAa;IACxB,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;;IAgB7B,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;IA4G3D,sBAAsB,CACpB,OAAO,EAAE,YAAY,EAAE,EACvB,QAAQ,EAAE,YAAY,EAAE,GACvB,MAAM;IA4CT,kBAAkB,CAChB,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,EACzC,WAAW,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GACzC,MAAM;IAmCT,OAAO,CAAC,YAAY;CA8CrB"}
1
+ {"version":3,"file":"IntentAuditor.d.ts","sourceRoot":"","sources":["../../src/services/IntentAuditor.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,mBAAmB,CAAC;AAE1C,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAOpD,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,WAAW,GAAG,QAAQ,CAAC;IAEjC,8EAA8E;IAC9E,WAAW,CAAC,EAAE,YAAY,EAAE,CAAC;IAC7B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,YAAY,EAAE,CAAC;IAC9B,+CAA+C;IAC/C,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,qEAAqE;IACrE,WAAW,CAAC,EAAE,UAAU,CAAC;CAC1B;AAED,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG,cAAc,GAAG,YAAY,CAAC;AAE7E,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,iBAAiB,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,UAAU,GAAG,cAAc,GAAG,OAAO,GAAG,UAAU,GAAG,IAAI,CAAC;IACzE,yEAAyE;IACzE,cAAc,CAAC,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5E;AAkED;;;;;;;;;GASG;AACH,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAID,qBAAa,aAAa;IACxB,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAEnB,IAAI,GAAE,oBAAyB;IA+BrC,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;IA4G3D,sBAAsB,CACpB,OAAO,EAAE,YAAY,EAAE,EACvB,QAAQ,EAAE,YAAY,EAAE,GACvB,MAAM;IA4CT,kBAAkB,CAChB,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,EACzC,WAAW,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GACzC,MAAM;IAmCT,OAAO,CAAC,YAAY;CA8CrB"}
@@ -95,14 +95,27 @@ RESPONSE FORMAT (strict JSON, no other text):
95
95
  "reason": "<one sentence explaining the verdict>",
96
96
  "inversionType": "polarity" | "quantitative" | "state" | "identity" | null
97
97
  }`;
98
- // ═══════════════════════════════════════════════════════════════════
99
- // IntentAuditor
100
- // ═══════════════════════════════════════════════════════════════════
98
+ const DEFAULT_AUDITOR_MODEL = "claude-haiku-4-5-20251001";
101
99
  class IntentAuditor {
102
100
  anthropic;
103
101
  disabled;
104
- constructor() {
105
- const apiKey = process.env.ANTHROPIC_API_KEY;
102
+ model;
103
+ constructor(opts = {}) {
104
+ this.model = opts.model ?? DEFAULT_AUDITOR_MODEL;
105
+ // Explicit `null` from the caller forces disabled mode — used by tests.
106
+ if (opts.anthropic === null) {
107
+ this.anthropic = null;
108
+ this.disabled = true;
109
+ return;
110
+ }
111
+ // Caller supplied a ready-to-use client → use it as-is.
112
+ if (opts.anthropic !== undefined) {
113
+ this.anthropic = opts.anthropic;
114
+ this.disabled = false;
115
+ return;
116
+ }
117
+ // Otherwise fall back to env-based construction (legacy default).
118
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
106
119
  if (!apiKey) {
107
120
  console.warn("[IntentAuditor] ANTHROPIC_API_KEY not set — auditor disabled, will return SUSPICIOUS for all audited heals");
108
121
  this.anthropic = null;
@@ -128,7 +141,7 @@ class IntentAuditor {
128
141
  .join("\n");
129
142
  try {
130
143
  const response = await this.anthropic.messages.create({
131
- model: "claude-haiku-4-5-20251001",
144
+ model: this.model,
132
145
  max_tokens: 256,
133
146
  temperature: 0.1,
134
147
  system: SYSTEM_PROMPT,
@@ -0,0 +1,24 @@
1
+ import type { PendingPromptEntry } from "./PendingPromptLedger";
2
+ export interface InteractiveReviewDeps {
3
+ entries: PendingPromptEntry[];
4
+ /** Override the prompt — tests stub Y/N answers without spinning up readline. */
5
+ prompt?: (question: string) => Promise<string>;
6
+ /** Override the console sink — tests capture output for assertions. */
7
+ log?: (msg: string) => void;
8
+ /** Override the filesystem rollback (rewrites contentBefore). Tests stub. */
9
+ rollback?: (entry: PendingPromptEntry) => void;
10
+ }
11
+ export interface InteractiveReviewOutcome {
12
+ file: string;
13
+ applied: boolean;
14
+ }
15
+ /**
16
+ * Run the post-run interactive review loop sequentially across every
17
+ * pending entry. Returns one outcome per entry so callers can log / test
18
+ * the resulting Apply / Reject decisions.
19
+ *
20
+ * Sequential by design — every prompt `await`s before the next entry is
21
+ * rendered. Concurrent prompts on a single TTY would interleave output.
22
+ */
23
+ export declare function runInteractiveReview(deps: InteractiveReviewDeps): Promise<InteractiveReviewOutcome[]>;
24
+ //# sourceMappingURL=InteractiveReview.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InteractiveReview.d.ts","sourceRoot":"","sources":["../../src/services/InteractiveReview.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,iFAAiF;IACjF,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/C,uEAAuE;IACvE,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAC;CAChD;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAID;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,wBAAwB,EAAE,CAAC,CA+CrC"}