ast-mcp-server 0.13.1 → 0.14.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.
@@ -1,11 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstatSync, realpathSync } from "node:fs";
3
- import { link, open, readFile, rename, stat, unlink } from "node:fs/promises";
2
+ import { constants, lstatSync, realpathSync } from "node:fs";
3
+ import { open, readFile, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { createTwoFilesPatch } from "diff";
6
6
  import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
7
- import { compareDiagnostics, normalizeDiagnostic, } from "./diagnostics.js";
7
+ import { compareObservedDiagnostics, observeDiagnostic, } from "./diagnostics.js";
8
+ import { probePublicationCapability, publishAuthenticated, removeOwnedPublicationEntry, rollbackOwnedCommit, snapshotHeldFile, } from "./authenticated-publication.js";
8
9
  import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProjectIfIdle, recordProjectMutationHistory, withProjectOperation, } from "./project.js";
10
+ import { PublicOperationalError } from "./public-errors.js";
9
11
  import { NO_REQUEST_CONTEXT } from "./request-context.js";
10
12
  import { executableDeclaration } from "./symbols.js";
11
13
  import { buildClassScaffold } from "./scaffold.js";
@@ -25,9 +27,9 @@ let operationNow = () => Date.now();
25
27
  function hash(content) {
26
28
  return createHash("sha256").update(content).digest("hex");
27
29
  }
28
- function planHashFor(operation) {
30
+ function planHashFor(operation, version = 2) {
29
31
  return hash(JSON.stringify({
30
- version: 1,
32
+ version,
31
33
  operation_id: operation.operation_id,
32
34
  kind: operation.kind,
33
35
  project_root: operation.project_root,
@@ -62,8 +64,29 @@ function pruneOperations() {
62
64
  operations.delete(oldest);
63
65
  }
64
66
  }
65
- function normalizeProjectDiagnostics(project, projectRoot, requestContext) {
66
- return [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()].map((diagnostic) => normalizeDiagnostic(diagnostic, projectRoot, requestContext));
67
+ function observeProjectDiagnostics(project, projectRoot, requestContext) {
68
+ return [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()].map((diagnostic, ordinal) => observeDiagnostic(diagnostic, projectRoot, ordinal, requestContext));
69
+ }
70
+ function collectPlannedTextChanges(project, projectRoot, originals, requestContext) {
71
+ const updated = new Map(project
72
+ .getSourceFiles()
73
+ .map((sourceFile) => [sourceFile.getFilePath(), sourceFile.getFullText()]));
74
+ const paths = new Set([...originals.keys(), ...updated.keys()]);
75
+ const changes = [];
76
+ for (const absolutePath of paths) {
77
+ requestContext.checkpoint();
78
+ const beforeText = originals.get(absolutePath) ?? null;
79
+ const afterText = updated.get(absolutePath) ?? null;
80
+ if (beforeText === afterText)
81
+ continue;
82
+ changes.push({
83
+ absolutePath,
84
+ file: path.relative(projectRoot, absolutePath),
85
+ beforeText,
86
+ afterText,
87
+ });
88
+ }
89
+ return changes.sort((left, right) => left.file < right.file ? -1 : left.file > right.file ? 1 : 0);
67
90
  }
68
91
  function decodeSource(bytes, filePath) {
69
92
  if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) {
@@ -164,7 +187,7 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
164
187
  const context = createFreshProject(projectRootInput);
165
188
  const workspaceBefore = createWorkspaceSnapshot(context);
166
189
  operationContext.checkpoint();
167
- const beforeDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot, operationContext);
190
+ const beforeObservations = observeProjectDiagnostics(context.project, context.projectRoot, operationContext);
168
191
  const originals = new Map(context.project
169
192
  .getSourceFiles()
170
193
  .map((sourceFile) => [sourceFile.getFilePath(), sourceFile.getFullText()]));
@@ -180,37 +203,45 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
180
203
  operationContext.checkpoint();
181
204
  const referenceCount = mutate(context.project, context.projectRoot);
182
205
  operationContext.checkpoint();
183
- const afterDiagnostics = normalizeProjectDiagnostics(context.project, context.projectRoot, operationContext);
184
- const diagnosticDelta = compareDiagnostics(beforeDiagnostics, afterDiagnostics, operationContext);
206
+ const afterObservations = observeProjectDiagnostics(context.project, context.projectRoot, operationContext);
207
+ const textChanges = collectPlannedTextChanges(context.project, context.projectRoot, originals, operationContext);
208
+ if (textChanges.length === 0) {
209
+ throw new Error("The structural operation produced no file changes.");
210
+ }
211
+ const comparisonWorkspace = createWorkspaceSnapshot(createFreshProject(context.tsConfigFilePath));
212
+ operationContext.checkpoint();
213
+ if (comparisonWorkspace.digest !== workspaceBefore.digest) {
214
+ throw new Error("Workspace changed while the operation was being prepared. Retry preparation.");
215
+ }
216
+ const diagnosticDelta = compareObservedDiagnostics(beforeObservations, afterObservations, textChanges, operationContext);
185
217
  const files = [];
186
- for (const sourceFile of context.project.getSourceFiles()) {
218
+ for (const { absolutePath, file, beforeText, afterText } of textChanges) {
187
219
  operationContext.checkpoint();
188
- const absolutePath = sourceFile.getFilePath();
189
- const originalText = originals.get(absolutePath);
190
- const updatedText = sourceFile.getFullText();
191
- if (originalText === undefined) {
192
- const file = path.relative(context.projectRoot, absolutePath);
220
+ if (afterText === null) {
221
+ throw new Error(`Structural operation deletion is unsupported: ${file}`);
222
+ }
223
+ if (beforeText === null) {
193
224
  assertSafeNewOperationFile(context.projectRoot, absolutePath, file);
194
225
  const originalBytes = Buffer.alloc(0);
195
- const updatedBytes = Buffer.from(updatedText, "utf8");
226
+ const updatedBytes = Buffer.from(afterText, "utf8");
196
227
  files.push({
197
228
  absolutePath,
198
229
  file,
199
230
  originalHash: ABSENT_FILE_HASH,
200
231
  updatedHash: hashBytes(updatedBytes),
201
232
  originalText: "",
202
- updatedText,
233
+ updatedText: afterText,
203
234
  originalBytes,
204
235
  updatedBytes,
205
- diff: createTwoFilesPatch("/dev/null", file, "", updatedText, "before", "after", {
236
+ diff: createTwoFilesPatch("/dev/null", file, "", afterText, "before", "after", {
206
237
  context: 3,
207
238
  }),
208
239
  mode: DEFAULT_NEW_FILE_MODE,
209
240
  });
210
241
  continue;
211
242
  }
212
- if (updatedText === originalText)
213
- continue;
243
+ const originalText = beforeText;
244
+ const updatedText = afterText;
214
245
  const originalBytes = await readFile(absolutePath);
215
246
  operationContext.checkpoint();
216
247
  if (decodeSource(originalBytes, absolutePath) !== originalText) {
@@ -219,7 +250,6 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
219
250
  const updatedBytes = encodeUpdatedSource(originalBytes, updatedText);
220
251
  const fileStat = await stat(absolutePath);
221
252
  operationContext.checkpoint();
222
- const file = path.relative(context.projectRoot, absolutePath);
223
253
  files.push({
224
254
  absolutePath,
225
255
  file,
@@ -235,10 +265,6 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
235
265
  mode: fileStat.mode,
236
266
  });
237
267
  }
238
- files.sort((left, right) => left.file.localeCompare(right.file));
239
- if (files.length === 0) {
240
- throw new Error("The structural operation produced no file changes.");
241
- }
242
268
  const postWorkspaceFiles = new Map(workspaceBefore.files);
243
269
  for (const file of files) {
244
270
  operationContext.checkpoint();
@@ -413,40 +439,106 @@ async function withWriteLock(key, requestContext, callback) {
413
439
  writeQueues.delete(key);
414
440
  }
415
441
  }
416
- async function safeUnlink(filePath) {
442
+ function matchesPrepared(identity, sha256, mode) {
443
+ return identity.sha256 === sha256 && identity.mode === (mode & 0o777);
444
+ }
445
+ async function stageFile(file, operationId) {
446
+ const parentPath = path.dirname(file.absolutePath);
447
+ const destinationBasename = path.basename(file.absolutePath);
448
+ const stageBasename = `${destinationBasename}.ast-mcp-${operationId}-${randomUUID()}.tmp`;
449
+ const temporaryPath = path.join(parentPath, stageBasename);
450
+ const handles = [];
451
+ let directory;
452
+ let stage;
417
453
  try {
418
- await unlink(filePath);
454
+ directory = await open(parentPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
455
+ handles.push(directory);
456
+ const heldParent = await directory.stat({ bigint: true });
457
+ const namedParent = lstatSync(parentPath, { bigint: true });
458
+ if (!heldParent.isDirectory() ||
459
+ !namedParent.isDirectory() ||
460
+ heldParent.dev !== namedParent.dev ||
461
+ heldParent.ino !== namedParent.ino) {
462
+ throw new Error("Publication parent identity changed during staging.");
463
+ }
464
+ stage = await open(temporaryPath, "wx", file.mode);
465
+ handles.push(stage);
466
+ await stage.writeFile(file.updatedBytes);
467
+ await stage.chmod(file.mode & 0o777);
468
+ await stage.sync();
469
+ const stageIdentity = await snapshotHeldFile(stage);
470
+ if (!matchesPrepared(stageIdentity, file.updatedHash, file.mode)) {
471
+ throw new Error("Staged publication does not match the reviewed postimage.");
472
+ }
473
+ const plan = {
474
+ kind: isCreatedFile(file) ? "creation" : "replacement",
475
+ directory,
476
+ destinationBasename,
477
+ stage,
478
+ stageBasename,
479
+ stageIdentity,
480
+ };
481
+ if (!isCreatedFile(file)) {
482
+ const preimage = await open(`/proc/self/fd/${directory.fd}/${destinationBasename}`, constants.O_RDONLY | constants.O_NOFOLLOW);
483
+ handles.push(preimage);
484
+ const preimageIdentity = await snapshotHeldFile(preimage);
485
+ if (!matchesPrepared(preimageIdentity, file.originalHash, file.mode)) {
486
+ throw new Error(`Conflict: ${file.file} changed while publication was staged.`);
487
+ }
488
+ plan.preimage = preimage;
489
+ plan.preimageIdentity = preimageIdentity;
490
+ }
491
+ return {
492
+ plan,
493
+ handles,
494
+ parentPath,
495
+ parentDev: heldParent.dev,
496
+ parentIno: heldParent.ino,
497
+ cleanupIdentity: plan.stageIdentity,
498
+ };
419
499
  }
420
500
  catch (error) {
421
- if (error.code !== "ENOENT")
422
- throw error;
501
+ if (directory && stage) {
502
+ try {
503
+ const currentStage = await snapshotHeldFile(stage);
504
+ await removeOwnedPublicationEntry(directory, stageBasename, currentStage);
505
+ }
506
+ catch {
507
+ // Preserve the original staging error; cleanup remains best effort.
508
+ }
509
+ }
510
+ await Promise.allSettled(handles.map((handle) => handle.close()));
511
+ throw error;
423
512
  }
424
513
  }
425
- async function stageFile(file, operationId) {
426
- const temporaryPath = `${file.absolutePath}.ast-mcp-${operationId}.tmp`;
427
- await safeUnlink(temporaryPath);
428
- const handle = await open(temporaryPath, "wx", file.mode);
429
- try {
430
- await handle.writeFile(file.updatedBytes);
431
- await handle.sync();
432
- }
433
- finally {
434
- await handle.close();
514
+ async function authenticateHeldParent(held) {
515
+ const current = lstatSync(held.parentPath, { bigint: true });
516
+ const opened = await held.plan.directory.stat({ bigint: true });
517
+ if (!current.isDirectory() ||
518
+ current.isSymbolicLink() ||
519
+ current.dev !== held.parentDev ||
520
+ current.ino !== held.parentIno ||
521
+ opened.dev !== held.parentDev ||
522
+ opened.ino !== held.parentIno) {
523
+ throw new Error("Conflict: publication parent identity changed before publication.");
435
524
  }
436
- return temporaryPath;
437
525
  }
438
- async function restoreFile(file, operationId) {
439
- const restorePath = `${file.absolutePath}.ast-mcp-${operationId}.restore`;
440
- await safeUnlink(restorePath);
441
- const handle = await open(restorePath, "wx", file.mode);
442
- try {
443
- await handle.writeFile(file.originalBytes);
444
- await handle.sync();
526
+ async function closeHeldPublication(held) {
527
+ await Promise.allSettled(held.handles.map((handle) => handle.close()));
528
+ }
529
+ async function cleanupHeldPublication(held) {
530
+ if (held.cleanupIdentity !== null) {
531
+ await removeOwnedPublicationEntry(held.plan.directory, held.plan.stageBasename, held.cleanupIdentity).catch(() => false);
445
532
  }
446
- finally {
447
- await handle.close();
533
+ await closeHeldPublication(held);
534
+ }
535
+ async function settleHeldPublications(held, preserveEntries = false) {
536
+ const publications = [...held];
537
+ if (preserveEntries) {
538
+ for (const publication of publications)
539
+ publication.cleanupIdentity = null;
448
540
  }
449
- await rename(restorePath, file.absolutePath);
541
+ await Promise.all(publications.map(preserveEntries ? closeHeldPublication : cleanupHeldPublication));
450
542
  }
451
543
  async function syncDirectories(files) {
452
544
  const directories = new Set(files.map((file) => path.dirname(file.absolutePath)));
@@ -598,8 +690,25 @@ export async function applyOperation(operationId, planHash, requestContext = NO_
598
690
  staged.set(file, await stageFile(file, operationId));
599
691
  operationContext.checkpoint();
600
692
  }
601
- for (const file of operation.files) {
693
+ const probedParents = new Set();
694
+ for (const [index, file] of operation.files.entries()) {
602
695
  operationContext.checkpoint();
696
+ try {
697
+ await operationTestHooks.onFilePhase?.({
698
+ operationId,
699
+ file: file.file,
700
+ index,
701
+ phase: "capability-preflight",
702
+ });
703
+ const parent = path.dirname(file.absolutePath);
704
+ if (!probedParents.has(parent)) {
705
+ await probePublicationCapability(parent);
706
+ probedParents.add(parent);
707
+ }
708
+ }
709
+ catch (cause) {
710
+ throw new PublicOperationalError("MUTATION_BLOCKED", "Authenticated publication capability is unavailable.", { cause });
711
+ }
603
712
  if (isCreatedFile(file)) {
604
713
  assertSafeNewOperationFile(operation.project_root, file.absolutePath, file.file);
605
714
  continue;
@@ -612,63 +721,98 @@ export async function applyOperation(operationId, planHash, requestContext = NO_
612
721
  }
613
722
  for (const [index, file] of operation.files.entries()) {
614
723
  operationContext.checkpoint();
615
- await operationTestHooks.beforeReplace?.(file.file, index);
724
+ await operationTestHooks.onFilePhase?.({
725
+ operationId,
726
+ file: file.file,
727
+ index,
728
+ phase: "before-publish",
729
+ });
730
+ const held = staged.get(file);
731
+ await authenticateHeldParent(held);
616
732
  if (!completionCritical) {
617
733
  operationContext.enterCompletionCritical();
618
734
  completionCritical = true;
619
735
  invalidateAfterCompletion = true;
620
736
  }
621
- if (isCreatedFile(file)) {
622
- await link(staged.get(file), file.absolutePath);
623
- applied.push(file);
624
- await safeUnlink(staged.get(file));
625
- staged.delete(file);
737
+ const publication = await publishAuthenticated(held.plan);
738
+ if (publication.state === "pre_effect") {
739
+ if (publication.reason === "unsupported") {
740
+ throw new PublicOperationalError("MUTATION_BLOCKED", "Authenticated publication capability is unavailable.");
741
+ }
742
+ throw new PublicOperationalError("CONFLICT", "Authenticated publication preserved a competing entry.");
626
743
  }
627
- else {
628
- await rename(staged.get(file), file.absolutePath);
629
- staged.delete(file);
630
- applied.push(file);
744
+ if (publication.state === "rolled_back") {
745
+ held.cleanupIdentity = held.plan.stageIdentity;
746
+ throw new PublicOperationalError("CONFLICT", "Authenticated publication preserved a competing entry.");
631
747
  }
632
- await operationTestHooks.afterReplace?.(file.file, index);
748
+ if (publication.state === "ambiguous") {
749
+ held.cleanupIdentity = null;
750
+ throw new PublicOperationalError("AMBIGUOUS_APPLY", "Authenticated publication ownership is ambiguous.");
751
+ }
752
+ held.committed = publication.token;
753
+ held.cleanupIdentity =
754
+ publication.token.kind === "replacement"
755
+ ? publication.token.displacedIdentity
756
+ : held.plan.stageIdentity;
757
+ applied.push(file);
758
+ await operationTestHooks.onFilePhase?.({
759
+ operationId,
760
+ file: file.file,
761
+ index,
762
+ phase: "after-commit",
763
+ });
633
764
  const writtenBytes = await readFile(file.absolutePath);
634
765
  if (hashBytes(writtenBytes) !== file.updatedHash) {
635
766
  throw new Error(`Post-write verification failed for ${file.file}.`);
636
767
  }
637
768
  }
638
769
  await syncDirectories(operation.files);
770
+ await settleHeldPublications(staged.values());
771
+ staged.clear();
639
772
  }
640
773
  catch (error) {
641
- const rollbackErrors = [];
774
+ if (PublicOperationalError.is(error) && error.code === "AMBIGUOUS_APPLY") {
775
+ await settleHeldPublications(staged.values(), true);
776
+ staged.clear();
777
+ throw error;
778
+ }
779
+ let rollbackAmbiguous = false;
642
780
  for (const file of [...applied].reverse()) {
781
+ const held = staged.get(file);
643
782
  try {
644
- const currentBytes = await readFile(file.absolutePath);
645
- if (hashBytes(currentBytes) !== file.updatedHash) {
646
- rollbackErrors.push(`${file.file}: current bytes no longer match this operation's postimage`);
647
- continue;
648
- }
649
- if (isCreatedFile(file)) {
650
- assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
651
- await safeUnlink(file.absolutePath);
652
- }
653
- else {
654
- await restoreFile(file, operationId);
783
+ await operationTestHooks.onFilePhase?.({
784
+ operationId,
785
+ file: file.file,
786
+ index: operation.files.indexOf(file),
787
+ phase: "before-rollback",
788
+ });
789
+ const rollback = await rollbackOwnedCommit(held.committed);
790
+ if (rollback.state !== "rolled_back") {
791
+ held.cleanupIdentity = null;
792
+ rollbackAmbiguous = true;
793
+ break;
655
794
  }
795
+ held.cleanupIdentity = held.committed.destinationIdentity;
656
796
  }
657
- catch (rollbackError) {
658
- rollbackErrors.push(`${file.file}: ${rollbackError instanceof Error
659
- ? rollbackError.message
660
- : String(rollbackError)}`);
797
+ catch {
798
+ held.cleanupIdentity = null;
799
+ rollbackAmbiguous = true;
800
+ break;
661
801
  }
662
802
  }
663
- for (const temporaryPath of staged.values())
664
- await safeUnlink(temporaryPath);
665
- if (applied.length > 0 && rollbackErrors.length === 0) {
803
+ if (rollbackAmbiguous) {
804
+ await settleHeldPublications(staged.values(), true);
805
+ staged.clear();
806
+ throw new PublicOperationalError("AMBIGUOUS_APPLY", "Authenticated rollback ownership is ambiguous.", { cause: error });
807
+ }
808
+ await settleHeldPublications(staged.values());
809
+ staged.clear();
810
+ if (applied.length > 0) {
666
811
  await syncDirectories(applied);
812
+ if (PublicOperationalError.is(error))
813
+ throw error;
667
814
  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 });
668
815
  }
669
- if (rollbackErrors.length > 0) {
670
- throw new Error(`Operation failed and rollback was incomplete: ${rollbackErrors.join("; ")}. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
671
- }
672
816
  throw error;
673
817
  }
674
818
  operation.status = "applied";
@@ -756,7 +900,7 @@ export function exportOperationRecord(operationId) {
756
900
  })),
757
901
  };
758
902
  }
759
- export function importOperationRecord(persisted, expectedPlanHash) {
903
+ export function importOperationRecord(persisted, expectedPlanHash, planVersion = 2) {
760
904
  if (persisted.plan_hash !== expectedPlanHash) {
761
905
  throw new Error(`Plan hash mismatch for operation "${persisted.operation_id}". No files were written.`);
762
906
  }
@@ -838,7 +982,7 @@ export function importOperationRecord(persisted, expectedPlanHash) {
838
982
  tsConfigFilePath: context.tsConfigFilePath,
839
983
  files,
840
984
  };
841
- if (planHashFor(record) !== expectedPlanHash) {
985
+ if (planHashFor(record, planVersion) !== expectedPlanHash) {
842
986
  throw new Error(`Persisted plan integrity mismatch for operation "${persisted.operation_id}". No files were written.`);
843
987
  }
844
988
  const existing = operations.get(record.operation_id);