ast-mcp-server 0.3.0

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.
@@ -0,0 +1,622 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstatSync, realpathSync } from "node:fs";
3
+ import { open, readFile, rename, stat, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { createTwoFilesPatch } from "diff";
6
+ import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
7
+ import { compareDiagnostics, normalizeDiagnostic, } from "./diagnostics.js";
8
+ import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProject, } from "./project.js";
9
+ import { executableDeclaration } from "./symbols.js";
10
+ import { withWorkspaceFileLock } from "./runtime-state.js";
11
+ import { createWorkspaceSnapshot, hashBytes, hashWorkspaceFiles } from "./workspace.js";
12
+ const DEFAULT_OPERATION_TTL_MS = 15 * 60 * 1000;
13
+ const DEFAULT_MAX_OPERATIONS = 100;
14
+ const MAX_INLINE_PREVIEW_CHARS = 100_000;
15
+ const operations = new Map();
16
+ const writeQueues = new Map();
17
+ let operationTestHooks = {};
18
+ let operationTtlMs = DEFAULT_OPERATION_TTL_MS;
19
+ let maxOperations = DEFAULT_MAX_OPERATIONS;
20
+ let operationNow = () => Date.now();
21
+ function hash(content) {
22
+ return createHash("sha256").update(content).digest("hex");
23
+ }
24
+ function planHashFor(operation) {
25
+ return hash(JSON.stringify({
26
+ version: 1,
27
+ operation_id: operation.operation_id,
28
+ kind: operation.kind,
29
+ project_root: operation.project_root,
30
+ created_at: operation.created_at,
31
+ expires_at: operation.expires_at,
32
+ reference_count: operation.reference_count,
33
+ workspace_hash: operation.workspace_hash,
34
+ post_workspace_hash: operation.post_workspace_hash,
35
+ workspace_file_count: operation.workspace_file_count,
36
+ diagnostics: operation.diagnostics,
37
+ allow_new_errors: operation.allow_new_errors,
38
+ blocked: operation.blocked,
39
+ files: operation.files.map((file) => ({
40
+ file: file.file,
41
+ original_hash: file.originalHash,
42
+ updated_hash: file.updatedHash,
43
+ mode: file.mode,
44
+ })),
45
+ }));
46
+ }
47
+ function pruneOperations() {
48
+ const now = operationNow();
49
+ for (const [id, operation] of operations) {
50
+ if (operation.status === "prepared" && Date.parse(operation.expires_at) <= now) {
51
+ operations.delete(id);
52
+ }
53
+ }
54
+ while (operations.size >= maxOperations) {
55
+ const oldest = operations.keys().next().value;
56
+ if (!oldest)
57
+ break;
58
+ operations.delete(oldest);
59
+ }
60
+ }
61
+ function normalizeProjectDiagnostics(project, projectRoot) {
62
+ return [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()].map((diagnostic) => normalizeDiagnostic(diagnostic, projectRoot));
63
+ }
64
+ function decodeSource(bytes, filePath) {
65
+ if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) {
66
+ throw new Error(`Unsupported UTF-16 source encoding in ${filePath}. Convert it to UTF-8 first.`);
67
+ }
68
+ try {
69
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
70
+ }
71
+ catch (error) {
72
+ throw new Error(`Unsupported non-UTF-8 source encoding in ${filePath}.`, { cause: error });
73
+ }
74
+ }
75
+ function encodeUpdatedSource(originalBytes, updatedText) {
76
+ const encoded = Buffer.from(updatedText, "utf8");
77
+ const hasUtf8Bom = originalBytes[0] === 0xef && originalBytes[1] === 0xbb && originalBytes[2] === 0xbf;
78
+ return hasUtf8Bom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), encoded]) : encoded;
79
+ }
80
+ function assertSafeOperationFile(projectRoot, absolutePath, displayPath) {
81
+ const fileStat = lstatSync(absolutePath);
82
+ if (fileStat.isSymbolicLink()) {
83
+ throw new Error(`Operation target must not be a symbolic link: ${displayPath}`);
84
+ }
85
+ if (!fileStat.isFile()) {
86
+ throw new Error(`Operation target is not a regular file: ${displayPath}`);
87
+ }
88
+ const canonicalPath = realpathSync(absolutePath);
89
+ const samePath = process.platform === "win32"
90
+ ? canonicalPath.toLowerCase() === absolutePath.toLowerCase()
91
+ : canonicalPath === absolutePath;
92
+ if (!samePath) {
93
+ throw new Error(`Operation target traverses a symbolic link: ${displayPath}`);
94
+ }
95
+ if (process.platform !== "win32" && fileStat.nlink !== 1) {
96
+ throw new Error(`Operation target must have exactly one hard link: ${displayPath}`);
97
+ }
98
+ const uid = process.getuid?.();
99
+ if (uid !== undefined && fileStat.uid !== uid) {
100
+ throw new Error(`Operation target is not owned by the current user: ${displayPath}`);
101
+ }
102
+ const relative = path.relative(projectRoot, canonicalPath);
103
+ if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`)) {
104
+ throw new Error(`Operation target escapes the project root: ${displayPath}`);
105
+ }
106
+ }
107
+ async function createPlan(projectRootInput, kind, allowNewErrors, mutate) {
108
+ pruneOperations();
109
+ const context = createFreshProject(projectRootInput);
110
+ const workspaceBefore = createWorkspaceSnapshot(context);
111
+ const beforeDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot);
112
+ const originals = new Map(context.project
113
+ .getSourceFiles()
114
+ .map((sourceFile) => [sourceFile.getFilePath(), sourceFile.getFullText()]));
115
+ for (const [filePath, sourceText] of originals) {
116
+ assertSafeOperationFile(context.projectRoot, filePath, path.relative(context.projectRoot, filePath));
117
+ const diskText = decodeSource(await readFile(filePath), filePath);
118
+ if (diskText !== sourceText) {
119
+ throw new Error(`Workspace changed while the operation was being prepared (${path.relative(context.projectRoot, filePath)}). Retry preparation.`);
120
+ }
121
+ }
122
+ const referenceCount = mutate(context.project, context.projectRoot);
123
+ const afterDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot);
124
+ const diagnosticDelta = compareDiagnostics(beforeDiagnostics, afterDiagnostics);
125
+ const files = [];
126
+ for (const sourceFile of context.project.getSourceFiles()) {
127
+ const absolutePath = sourceFile.getFilePath();
128
+ const originalText = originals.get(absolutePath);
129
+ if (originalText === undefined)
130
+ continue;
131
+ const updatedText = sourceFile.getFullText();
132
+ if (updatedText === originalText)
133
+ continue;
134
+ const originalBytes = await readFile(absolutePath);
135
+ if (decodeSource(originalBytes, absolutePath) !== originalText) {
136
+ throw new Error(`Workspace changed while the operation was being prepared (${path.relative(context.projectRoot, absolutePath)}). Retry preparation.`);
137
+ }
138
+ const updatedBytes = encodeUpdatedSource(originalBytes, updatedText);
139
+ const fileStat = await stat(absolutePath);
140
+ const file = path.relative(context.projectRoot, absolutePath);
141
+ files.push({
142
+ absolutePath,
143
+ file,
144
+ originalHash: hashBytes(originalBytes),
145
+ updatedHash: hashBytes(updatedBytes),
146
+ originalText,
147
+ updatedText,
148
+ originalBytes,
149
+ updatedBytes,
150
+ diff: createTwoFilesPatch(file, file, originalText, updatedText, "before", "after", {
151
+ context: 3,
152
+ }),
153
+ mode: fileStat.mode,
154
+ });
155
+ }
156
+ files.sort((left, right) => left.file.localeCompare(right.file));
157
+ if (files.length === 0) {
158
+ throw new Error("The structural operation produced no file changes.");
159
+ }
160
+ const postWorkspaceFiles = new Map(workspaceBefore.files);
161
+ for (const file of files) {
162
+ if (!postWorkspaceFiles.has(file.absolutePath)) {
163
+ throw new Error(`Affected file is missing from the workspace snapshot: ${file.file}`);
164
+ }
165
+ postWorkspaceFiles.set(file.absolutePath, file.updatedHash);
166
+ }
167
+ const postWorkspaceHash = hashWorkspaceFiles(postWorkspaceFiles);
168
+ const workspaceAfter = createWorkspaceSnapshot(context);
169
+ if (workspaceAfter.digest !== workspaceBefore.digest) {
170
+ throw new Error("Workspace changed while the operation was being prepared. Retry preparation.");
171
+ }
172
+ const fullPreview = files.map((file) => file.diff).join("\n");
173
+ const blocked = !allowNewErrors && diagnosticDelta.addedErrors.length > 0;
174
+ const now = operationNow();
175
+ const operationId = randomUUID();
176
+ const recordWithoutHash = {
177
+ operation_id: operationId,
178
+ kind,
179
+ status: "prepared",
180
+ project_root: context.projectRoot,
181
+ tsConfigFilePath: context.tsConfigFilePath,
182
+ created_at: new Date(now).toISOString(),
183
+ expires_at: new Date(now + operationTtlMs).toISOString(),
184
+ affected_files: files.map((file) => ({
185
+ file: file.file,
186
+ original_hash: file.originalHash,
187
+ updated_hash: file.updatedHash,
188
+ })),
189
+ reference_count: referenceCount,
190
+ workspace_hash: workspaceBefore.digest,
191
+ post_workspace_hash: postWorkspaceHash,
192
+ workspace_file_count: workspaceBefore.fileCount,
193
+ diagnostics: diagnosticDelta,
194
+ allow_new_errors: allowNewErrors,
195
+ blocked,
196
+ block_reason: blocked
197
+ ? `${diagnosticDelta.addedErrors.length} new TypeScript error(s) would be introduced. Prepare again with allow_new_errors=true only after explicit review.`
198
+ : null,
199
+ preview: fullPreview.length <= MAX_INLINE_PREVIEW_CHARS ? fullPreview : null,
200
+ preview_truncated: fullPreview.length > MAX_INLINE_PREVIEW_CHARS,
201
+ files,
202
+ };
203
+ const record = {
204
+ ...recordWithoutHash,
205
+ plan_hash: planHashFor(recordWithoutHash),
206
+ };
207
+ operations.set(operationId, record);
208
+ return publicOperation(record);
209
+ }
210
+ function publicOperation(record) {
211
+ return {
212
+ operation_id: record.operation_id,
213
+ plan_hash: record.plan_hash,
214
+ kind: record.kind,
215
+ status: record.status,
216
+ project_root: record.project_root,
217
+ created_at: record.created_at,
218
+ expires_at: record.expires_at,
219
+ affected_files: record.affected_files,
220
+ reference_count: record.reference_count,
221
+ workspace_hash: record.workspace_hash,
222
+ post_workspace_hash: record.post_workspace_hash,
223
+ workspace_file_count: record.workspace_file_count,
224
+ diagnostics: record.diagnostics,
225
+ allow_new_errors: record.allow_new_errors,
226
+ blocked: record.blocked,
227
+ block_reason: record.block_reason,
228
+ preview: record.preview,
229
+ preview_truncated: record.preview_truncated,
230
+ };
231
+ }
232
+ export async function prepareRename(args) {
233
+ const scanner = ts.createScanner(ScriptTarget.Latest, false, LanguageVariant.Standard, args.newName);
234
+ const validIdentifier = scanner.scan() === SyntaxKind.Identifier && scanner.scan() === SyntaxKind.EndOfFileToken;
235
+ if (!validIdentifier) {
236
+ throw new Error(`"${args.newName}" is not a valid TypeScript identifier.`);
237
+ }
238
+ return createPlan(args.projectRoot, "rename_symbol", args.allowNewErrors ?? false, (project) => {
239
+ const sourceFile = getSourceFileOrThrow(project, args.filePath);
240
+ const node = findDeclarationByName(sourceFile, args.symbolPath);
241
+ if (!Node.isRenameable(node)) {
242
+ throw new Error(`Symbol "${args.symbolPath}" (${node.getKindName()}) does not support structural rename.`);
243
+ }
244
+ const referenceCount = Node.isReferenceFindable(node) ? node.findReferencesAsNodes().length : 0;
245
+ node.rename(args.newName);
246
+ return referenceCount;
247
+ });
248
+ }
249
+ export async function prepareReplaceBody(args) {
250
+ return createPlan(args.projectRoot, "replace_symbol_body", args.allowNewErrors ?? false, (project) => {
251
+ const sourceFile = getSourceFileOrThrow(project, args.filePath);
252
+ const declaration = findDeclarationByName(sourceFile, args.symbolPath);
253
+ const executable = executableDeclaration(declaration);
254
+ if (typeof executable.setBodyText !== "function") {
255
+ throw new Error(`Symbol "${args.symbolPath}" does not expose a replaceable body.`);
256
+ }
257
+ if (Node.isArrowFunction(executable) && !Node.isBlock(executable.getBody())) {
258
+ executable.getBody().replaceWithText("{}");
259
+ }
260
+ executable.setBodyText(args.newBody);
261
+ return 0;
262
+ });
263
+ }
264
+ async function withWriteLock(key, callback) {
265
+ const previous = writeQueues.get(key) ?? Promise.resolve();
266
+ let release;
267
+ const next = new Promise((resolve) => {
268
+ release = resolve;
269
+ });
270
+ writeQueues.set(key, next);
271
+ await previous;
272
+ try {
273
+ return await callback();
274
+ }
275
+ finally {
276
+ release();
277
+ if (writeQueues.get(key) === next)
278
+ writeQueues.delete(key);
279
+ }
280
+ }
281
+ async function safeUnlink(filePath) {
282
+ try {
283
+ await unlink(filePath);
284
+ }
285
+ catch (error) {
286
+ if (error.code !== "ENOENT")
287
+ throw error;
288
+ }
289
+ }
290
+ async function stageFile(file, operationId) {
291
+ const temporaryPath = `${file.absolutePath}.ast-mcp-${operationId}.tmp`;
292
+ await safeUnlink(temporaryPath);
293
+ const handle = await open(temporaryPath, "wx", file.mode);
294
+ try {
295
+ await handle.writeFile(file.updatedBytes);
296
+ await handle.sync();
297
+ }
298
+ finally {
299
+ await handle.close();
300
+ }
301
+ return temporaryPath;
302
+ }
303
+ async function restoreFile(file, operationId) {
304
+ const restorePath = `${file.absolutePath}.ast-mcp-${operationId}.restore`;
305
+ await safeUnlink(restorePath);
306
+ const handle = await open(restorePath, "wx", file.mode);
307
+ try {
308
+ await handle.writeFile(file.originalBytes);
309
+ await handle.sync();
310
+ }
311
+ finally {
312
+ await handle.close();
313
+ }
314
+ await rename(restorePath, file.absolutePath);
315
+ }
316
+ async function syncDirectories(files) {
317
+ const directories = new Set(files.map((file) => path.dirname(file.absolutePath)));
318
+ for (const directory of directories) {
319
+ const handle = await open(directory, "r");
320
+ try {
321
+ await handle.sync();
322
+ }
323
+ finally {
324
+ await handle.close();
325
+ }
326
+ }
327
+ }
328
+ function appliedResult(operation, idempotentReplay) {
329
+ return {
330
+ operation_id: operation.operation_id,
331
+ kind: operation.kind,
332
+ status: "applied",
333
+ applied_at: operation.applied_at,
334
+ affected_files: operation.files.map((file) => file.file),
335
+ idempotent_replay: idempotentReplay,
336
+ };
337
+ }
338
+ export function configureOperationApply(operationId, options) {
339
+ const operation = operations.get(operationId);
340
+ if (!operation) {
341
+ throw new Error(`Prepared operation "${operationId}" was not found or has expired.`);
342
+ }
343
+ operation.receiptWriter = options.receiptWriter;
344
+ operation.lockStateDirectory = options.stateDirectory;
345
+ }
346
+ export async function applyOperation(operationId, planHash) {
347
+ const operation = operations.get(operationId);
348
+ if (!operation) {
349
+ throw new Error(`Prepared operation "${operationId}" was not found or has expired.`);
350
+ }
351
+ if (planHash !== operation.plan_hash) {
352
+ throw new Error(`Plan hash mismatch for operation "${operationId}". No files were written.`);
353
+ }
354
+ const persistReceipt = async () => {
355
+ if (!operation.receiptWriter)
356
+ return;
357
+ try {
358
+ await operation.receiptWriter();
359
+ }
360
+ catch (error) {
361
+ throw new Error("Operation sources are at the verified postimages, but receipt persistence failed. Retry apply with the same reviewed plan after resolving the storage error.", { cause: error });
362
+ }
363
+ };
364
+ return withWriteLock(operation.tsConfigFilePath, async () => withWorkspaceFileLock(operation.tsConfigFilePath, { stateDirectory: operation.lockStateDirectory }, async () => {
365
+ if (operation.status === "applied") {
366
+ for (const file of operation.files) {
367
+ const currentBytes = await readFile(file.absolutePath);
368
+ if (hashBytes(currentBytes) !== file.updatedHash) {
369
+ throw new Error(`Applied receipt conflict: ${file.file} no longer matches the recorded postimage.`);
370
+ }
371
+ }
372
+ await persistReceipt();
373
+ return appliedResult(operation, true);
374
+ }
375
+ if (Date.parse(operation.expires_at) <= operationNow()) {
376
+ operations.delete(operationId);
377
+ throw new Error(`Prepared operation "${operationId}" has expired.`);
378
+ }
379
+ if (operation.blocked) {
380
+ throw new Error(operation.block_reason ?? "The operation is blocked by validation errors.");
381
+ }
382
+ for (const file of operation.files) {
383
+ assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
384
+ }
385
+ const currentContext = createFreshProject(operation.tsConfigFilePath);
386
+ const currentWorkspace = createWorkspaceSnapshot(currentContext);
387
+ if (currentWorkspace.digest === operation.post_workspace_hash) {
388
+ for (const file of operation.files) {
389
+ const currentBytes = await readFile(file.absolutePath);
390
+ if (hashBytes(currentBytes) !== file.updatedHash) {
391
+ throw new Error(`Postimage recovery conflict: ${file.file} does not match the reviewed postimage.`);
392
+ }
393
+ }
394
+ operation.status = "applied";
395
+ operation.applied_at ??= new Date().toISOString();
396
+ invalidateProject(operation.tsConfigFilePath);
397
+ await persistReceipt();
398
+ return appliedResult(operation, true);
399
+ }
400
+ if (currentWorkspace.digest !== operation.workspace_hash) {
401
+ throw new Error("Conflict: the project source/config workspace changed after preparation. No files were written.");
402
+ }
403
+ for (const file of operation.files) {
404
+ const currentBytes = await readFile(file.absolutePath);
405
+ if (hashBytes(currentBytes) !== file.originalHash) {
406
+ throw new Error(`Conflict: ${file.file} changed after the operation was prepared. No files were written.`);
407
+ }
408
+ }
409
+ const staged = new Map();
410
+ const applied = [];
411
+ try {
412
+ for (const file of operation.files) {
413
+ staged.set(file, await stageFile(file, operationId));
414
+ }
415
+ for (const file of operation.files) {
416
+ const currentBytes = await readFile(file.absolutePath);
417
+ if (hashBytes(currentBytes) !== file.originalHash) {
418
+ throw new Error(`Conflict: ${file.file} changed while the operation was being staged. No files were written.`);
419
+ }
420
+ }
421
+ for (const [index, file] of operation.files.entries()) {
422
+ await operationTestHooks.beforeReplace?.(file.file, index);
423
+ await rename(staged.get(file), file.absolutePath);
424
+ staged.delete(file);
425
+ applied.push(file);
426
+ const writtenBytes = await readFile(file.absolutePath);
427
+ if (hashBytes(writtenBytes) !== file.updatedHash) {
428
+ throw new Error(`Post-write verification failed for ${file.file}.`);
429
+ }
430
+ }
431
+ await syncDirectories(operation.files);
432
+ }
433
+ catch (error) {
434
+ const rollbackErrors = [];
435
+ for (const file of [...applied].reverse()) {
436
+ try {
437
+ const currentBytes = await readFile(file.absolutePath);
438
+ if (hashBytes(currentBytes) !== file.updatedHash) {
439
+ rollbackErrors.push(`${file.file}: current bytes no longer match this operation's postimage`);
440
+ continue;
441
+ }
442
+ await restoreFile(file, operationId);
443
+ }
444
+ catch (rollbackError) {
445
+ rollbackErrors.push(`${file.file}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
446
+ }
447
+ }
448
+ for (const temporaryPath of staged.values())
449
+ await safeUnlink(temporaryPath);
450
+ if (applied.length > 0 && rollbackErrors.length === 0) {
451
+ await syncDirectories(applied);
452
+ throw new Error(`Operation failed after replacing ${applied.length} file(s); rollback succeeded and original bytes were restored. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
453
+ }
454
+ if (rollbackErrors.length > 0) {
455
+ throw new Error(`Operation failed and rollback was incomplete: ${rollbackErrors.join("; ")}. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
456
+ }
457
+ throw error;
458
+ }
459
+ operation.status = "applied";
460
+ operation.applied_at = new Date().toISOString();
461
+ invalidateProject(operation.tsConfigFilePath);
462
+ await persistReceipt();
463
+ return appliedResult(operation, false);
464
+ }));
465
+ }
466
+ export function getOperationPreview(operationId, file) {
467
+ const operation = operations.get(operationId);
468
+ if (!operation) {
469
+ throw new Error(`Prepared operation "${operationId}" was not found or has expired.`);
470
+ }
471
+ const files = file ? operation.files.filter((item) => item.file === file) : operation.files;
472
+ if (file && files.length === 0) {
473
+ throw new Error(`File "${file}" is not part of operation "${operationId}".`);
474
+ }
475
+ return {
476
+ operation_id: operationId,
477
+ plan_hash: operation.plan_hash,
478
+ files: files.map((item) => ({ file: item.file, diff: item.diff })),
479
+ };
480
+ }
481
+ function decodePersistedBytes(value, label) {
482
+ const bytes = Buffer.from(value, "base64");
483
+ if (bytes.toString("base64") !== value) {
484
+ throw new Error(`Persisted operation contains invalid base64 for ${label}.`);
485
+ }
486
+ return bytes;
487
+ }
488
+ function containedOperationPath(projectRoot, file) {
489
+ if (path.isAbsolute(file)) {
490
+ throw new Error(`Persisted operation file path must be project-relative: ${file}`);
491
+ }
492
+ const absolutePath = path.resolve(projectRoot, file);
493
+ const relative = path.relative(projectRoot, absolutePath);
494
+ if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`)) {
495
+ throw new Error(`Persisted operation file escapes the project root: ${file}`);
496
+ }
497
+ if (relative !== file.split(path.posix.sep).join(path.sep)) {
498
+ throw new Error(`Persisted operation file path is not normalized: ${file}`);
499
+ }
500
+ assertSafeOperationFile(projectRoot, absolutePath, file);
501
+ return absolutePath;
502
+ }
503
+ export function exportOperationRecord(operationId) {
504
+ const operation = operations.get(operationId);
505
+ if (!operation) {
506
+ throw new Error(`Prepared operation "${operationId}" was not found or has expired.`);
507
+ }
508
+ return {
509
+ ...publicOperation(operation),
510
+ ...(operation.applied_at ? { applied_at: operation.applied_at } : {}),
511
+ files: operation.files.map((file) => ({
512
+ file: file.file,
513
+ original_hash: file.originalHash,
514
+ updated_hash: file.updatedHash,
515
+ original_bytes_base64: file.originalBytes.toString("base64"),
516
+ updated_bytes_base64: file.updatedBytes.toString("base64"),
517
+ mode: file.mode,
518
+ })),
519
+ };
520
+ }
521
+ export function importOperationRecord(persisted, expectedPlanHash) {
522
+ if (persisted.plan_hash !== expectedPlanHash) {
523
+ throw new Error(`Plan hash mismatch for operation "${persisted.operation_id}". No files were written.`);
524
+ }
525
+ if (persisted.status === "prepared" && Date.parse(persisted.expires_at) <= operationNow()) {
526
+ throw new Error(`Prepared operation "${persisted.operation_id}" has expired.`);
527
+ }
528
+ if (persisted.status === "applied" && !persisted.applied_at) {
529
+ throw new Error("Persisted applied operation is missing applied_at.");
530
+ }
531
+ if (persisted.status === "prepared" && persisted.applied_at) {
532
+ throw new Error("Persisted prepared operation cannot contain applied_at.");
533
+ }
534
+ const context = createFreshProject(persisted.project_root);
535
+ if (context.projectRoot !== persisted.project_root) {
536
+ throw new Error("Persisted project_root is not canonical.");
537
+ }
538
+ const files = persisted.files.map((file) => {
539
+ const absolutePath = containedOperationPath(context.projectRoot, file.file);
540
+ const originalBytes = decodePersistedBytes(file.original_bytes_base64, `${file.file} original bytes`);
541
+ const updatedBytes = decodePersistedBytes(file.updated_bytes_base64, `${file.file} updated bytes`);
542
+ if (hashBytes(originalBytes) !== file.original_hash) {
543
+ throw new Error(`Persisted original hash mismatch for ${file.file}.`);
544
+ }
545
+ if (hashBytes(updatedBytes) !== file.updated_hash) {
546
+ throw new Error(`Persisted updated hash mismatch for ${file.file}.`);
547
+ }
548
+ const originalText = decodeSource(originalBytes, absolutePath);
549
+ const updatedText = decodeSource(updatedBytes, absolutePath);
550
+ return {
551
+ absolutePath,
552
+ file: file.file,
553
+ originalHash: file.original_hash,
554
+ updatedHash: file.updated_hash,
555
+ originalText,
556
+ updatedText,
557
+ originalBytes,
558
+ updatedBytes,
559
+ diff: createTwoFilesPatch(file.file, file.file, originalText, updatedText, "before", "after", { context: 3 }),
560
+ mode: file.mode,
561
+ };
562
+ });
563
+ files.sort((left, right) => left.file.localeCompare(right.file));
564
+ if (files.length === 0 || new Set(files.map((file) => file.file)).size !== files.length) {
565
+ throw new Error("Persisted operation must contain unique affected files.");
566
+ }
567
+ const affectedFiles = files.map((file) => ({
568
+ file: file.file,
569
+ original_hash: file.originalHash,
570
+ updated_hash: file.updatedHash,
571
+ }));
572
+ if (JSON.stringify(affectedFiles) !== JSON.stringify(persisted.affected_files)) {
573
+ throw new Error("Persisted affected_files do not match the exact file payloads.");
574
+ }
575
+ const addedErrors = persisted.diagnostics.added.filter((diagnostic) => diagnostic.category === "Error");
576
+ if (JSON.stringify(addedErrors) !== JSON.stringify(persisted.diagnostics.addedErrors)) {
577
+ throw new Error("Persisted diagnostic error delta is inconsistent.");
578
+ }
579
+ const blocked = !persisted.allow_new_errors && addedErrors.length > 0;
580
+ if (persisted.blocked !== blocked) {
581
+ throw new Error("Persisted blocked status is inconsistent with diagnostics policy.");
582
+ }
583
+ const fullPreview = files.map((file) => file.diff).join("\n");
584
+ const record = {
585
+ ...persisted,
586
+ affected_files: affectedFiles,
587
+ blocked,
588
+ block_reason: blocked
589
+ ? `${addedErrors.length} new TypeScript error(s) would be introduced. Prepare again with allow_new_errors=true only after explicit review.`
590
+ : null,
591
+ preview: fullPreview.length <= MAX_INLINE_PREVIEW_CHARS ? fullPreview : null,
592
+ preview_truncated: fullPreview.length > MAX_INLINE_PREVIEW_CHARS,
593
+ tsConfigFilePath: context.tsConfigFilePath,
594
+ files,
595
+ };
596
+ if (planHashFor(record) !== expectedPlanHash) {
597
+ throw new Error(`Persisted plan integrity mismatch for operation "${persisted.operation_id}". No files were written.`);
598
+ }
599
+ const existing = operations.get(record.operation_id);
600
+ if (existing && existing.plan_hash !== record.plan_hash) {
601
+ throw new Error(`Operation id collision for "${record.operation_id}".`);
602
+ }
603
+ pruneOperations();
604
+ operations.set(record.operation_id, record);
605
+ return publicOperation(record);
606
+ }
607
+ export function setOperationTestHooksForTests(hooks) {
608
+ operationTestHooks = hooks;
609
+ }
610
+ export function setOperationStoreConfigForTests(options) {
611
+ operationNow = options.now ?? operationNow;
612
+ operationTtlMs = options.ttlMs ?? operationTtlMs;
613
+ maxOperations = options.maxOperations ?? maxOperations;
614
+ }
615
+ export function clearOperationsForTests() {
616
+ operations.clear();
617
+ writeQueues.clear();
618
+ operationTestHooks = {};
619
+ operationTtlMs = DEFAULT_OPERATION_TTL_MS;
620
+ maxOperations = DEFAULT_MAX_OPERATIONS;
621
+ operationNow = () => Date.now();
622
+ }