@martinloop/mcp 0.8.6 → 0.8.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.
@@ -1,6 +1,18 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
4
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { dirname, relative, resolve } from "node:path";
5
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
6
+ import { DEPENDENCY_AND_BUILD_DIRECTORIES, isInsideDependencyOrBuildDirectory } from "./output-directories.js";
7
+ /** Past this many ignored files, further ignored directories stay collapsed. */
8
+ const MAX_EXPANDED_IGNORED_FILES = 20_000;
9
+ const MAX_HASHED_IGNORED_FILE_BYTES = 8 * 1024 * 1024;
10
+ const MAX_HASHED_IGNORED_TOTAL_BYTES = 16 * 1024 * 1024;
11
+ const MAX_IGNORED_SNAPSHOT_BYTES = 64 * 1024 * 1024;
12
+ const ignoredContentSnapshots = new WeakMap();
13
+ const ignoredObservations = new WeakMap();
14
+ /** Git listings of large untracked or ignored trees exceed spawnSync's 1 MiB default. */
15
+ const GIT_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
4
16
  export class RollbackStateUnavailableError extends Error {
5
17
  constructor(message) {
6
18
  super(message);
@@ -11,7 +23,9 @@ export function listAttemptChangedFilesSinceBoundary(input) {
11
23
  if (!input.repoRoot) {
12
24
  return [];
13
25
  }
14
- const repoState = readRepoState(input.repoRoot);
26
+ // Diff against the boundary commit (not the live HEAD) so that changes an
27
+ // attempt commits are still reported as attempt changes.
28
+ const repoState = readRepoState(input.repoRoot, input.boundary?.headRef);
15
29
  if (!input.boundary) {
16
30
  return uniqueSorted([...repoState.trackedDirtyFiles, ...repoState.untrackedFiles]);
17
31
  }
@@ -19,7 +33,10 @@ export function listAttemptChangedFilesSinceBoundary(input) {
19
33
  const baselineUntracked = new Set(input.boundary.untrackedFiles);
20
34
  return uniqueSorted([
21
35
  ...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)),
22
- ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath))
36
+ ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)),
37
+ ...(input.includeBaselineSnapshotChanges
38
+ ? snapshotChangedPaths(input.repoRoot, input.boundary.snapshots)
39
+ : [])
23
40
  ]);
24
41
  }
25
42
  export async function captureRollbackBoundary(input) {
@@ -27,23 +44,85 @@ export async function captureRollbackBoundary(input) {
27
44
  return undefined;
28
45
  }
29
46
  const repoState = readRepoState(input.repoRoot);
47
+ // Links are recorded by path only: their targets are never read or restored.
30
48
  const snapshotPaths = uniqueSorted([
31
49
  ...repoState.trackedDirtyFiles,
32
50
  ...repoState.untrackedFiles
33
- ]);
51
+ ]).filter((filePath) => !isLinkPath(input.repoRoot, filePath));
34
52
  const snapshots = [];
35
53
  for (const filePath of snapshotPaths) {
36
54
  snapshots.push(await readRollbackSnapshot(input.repoRoot, filePath));
37
55
  }
56
+ const observedIgnoredFiles = readIgnoredInventory(input.repoRoot);
57
+ const ignoredFiles = observedIgnoredFiles.map(({ path, size, mtimeMs }) => ({
58
+ path,
59
+ size,
60
+ mtimeMs
61
+ }));
38
62
  const headRef = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
39
- return {
63
+ const headBranch = readHeadBranch(input.repoRoot);
64
+ const boundary = {
40
65
  strategy: "git_head_plus_snapshot",
41
66
  capturedAt: input.capturedAt,
42
67
  ...(headRef ? { headRef } : {}),
68
+ ...(headBranch ? { headBranch } : {}),
43
69
  trackedDirtyFiles: repoState.trackedDirtyFiles,
44
70
  untrackedFiles: repoState.untrackedFiles,
45
- snapshots
71
+ snapshots,
72
+ ignoredFiles
46
73
  };
74
+ ignoredObservations.set(boundary, new Map(observedIgnoredFiles.map((entry) => [entry.path, entry])));
75
+ ignoredContentSnapshots.set(boundary, captureIgnoredContent(input.repoRoot, ignoredFiles));
76
+ return boundary;
77
+ }
78
+ /**
79
+ * Git-ignored files the attempt created or modified since the boundary.
80
+ * Boundaries recorded before the ignored inventory existed report nothing.
81
+ */
82
+ export function listIgnoredFileChangesSinceBoundary(input) {
83
+ if (!input.repoRoot || !input.boundary?.ignoredFiles) {
84
+ return makeIgnoredFileChanges([], [], []);
85
+ }
86
+ const baseline = new Map(input.boundary.ignoredFiles.map((entry) => [entry.path, entry]));
87
+ const baselineObservations = ignoredObservations.get(input.boundary);
88
+ const created = [];
89
+ const modified = [];
90
+ const currentEntries = readIgnoredInventory(input.repoRoot);
91
+ const current = new Map(currentEntries.map((entry) => [entry.path, entry]));
92
+ const deleted = input.boundary.ignoredFiles
93
+ .filter((entry) => !current.has(entry.path))
94
+ .map((entry) => entry.path);
95
+ for (const entry of currentEntries) {
96
+ const previous = baseline.get(entry.path);
97
+ if (!previous) {
98
+ created.push(entry.path);
99
+ }
100
+ else if (!entry.path.endsWith("/") && ignoredObservationChanged(previous, baselineObservations?.get(entry.path), entry)) {
101
+ modified.push(entry.path);
102
+ }
103
+ }
104
+ return makeIgnoredFileChanges(created.sort(), modified.sort(), deleted.sort());
105
+ }
106
+ function ignoredObservationChanged(previous, previousObservation, current) {
107
+ if (previousObservation?.sha256 !== undefined && current.sha256 !== undefined) {
108
+ return previousObservation.sha256 !== current.sha256;
109
+ }
110
+ if (previousObservation !== undefined) {
111
+ return previous.size !== current.size ||
112
+ previous.mtimeMs !== current.mtimeMs ||
113
+ previousObservation.ctimeMs !== current.ctimeMs;
114
+ }
115
+ return previous.size !== current.size || previous.mtimeMs !== current.mtimeMs;
116
+ }
117
+ function makeIgnoredFileChanges(created, modified, deleted) {
118
+ const changes = { created, modified };
119
+ // Preserve the historical enumerable shape when there are no deletions while
120
+ // still exposing deletion evidence to current callers.
121
+ Object.defineProperty(changes, "deleted", {
122
+ value: deleted,
123
+ enumerable: deleted.length > 0
124
+ });
125
+ return changes;
47
126
  }
48
127
  export async function restoreRollbackBoundary(input) {
49
128
  if (!input.repoRoot) {
@@ -62,8 +141,17 @@ export async function restoreRollbackBoundary(input) {
62
141
  error: "Rollback boundary was unavailable for this attempt."
63
142
  };
64
143
  }
65
- const before = readRepoState(input.repoRoot);
66
- if (repoStateMatchesBoundary(before, input.boundary)) {
144
+ const before = readRepoState(input.repoRoot, input.boundary.headRef);
145
+ const currentHead = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
146
+ const headMoved = input.boundary.headRef !== undefined && currentHead !== input.boundary.headRef;
147
+ const removePaths = uniqueSorted(input.removePaths ?? []);
148
+ const restoreIgnoredPaths = uniqueSorted(input.restoreIgnoredPaths ?? []);
149
+ const snapshotsUnchanged = await rollbackSnapshotsMatch(input.repoRoot, input.boundary.snapshots);
150
+ if (!headMoved &&
151
+ removePaths.length === 0 &&
152
+ restoreIgnoredPaths.length === 0 &&
153
+ repoStateMatchesBoundary(before, input.boundary) &&
154
+ snapshotsUnchanged) {
67
155
  return {
68
156
  attempted: false,
69
157
  status: "not_required",
@@ -77,13 +165,25 @@ export async function restoreRollbackBoundary(input) {
77
165
  }
78
166
  const restoredFiles = new Set();
79
167
  const deletedFiles = new Set();
168
+ let rewoundHead;
80
169
  try {
170
+ if (headMoved && input.boundary.headRef && currentHead) {
171
+ rewindAttemptCommits(input.repoRoot, input.boundary, currentHead);
172
+ rewoundHead = { from: currentHead, to: input.boundary.headRef };
173
+ }
81
174
  const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
82
175
  const baselineUntracked = new Set(input.boundary.untrackedFiles);
83
176
  for (const filePath of before.trackedDirtyFiles) {
84
177
  if (!baselineTracked.has(filePath)) {
85
- restoreTrackedFileFromHead(input.repoRoot, filePath);
86
- restoredFiles.add(filePath);
178
+ if (existsInHead(input.repoRoot, filePath)) {
179
+ restoreTrackedFileFromHead(input.repoRoot, filePath);
180
+ restoredFiles.add(filePath);
181
+ }
182
+ else {
183
+ unstagePath(input.repoRoot, filePath);
184
+ await removeRepoPath(input.repoRoot, filePath);
185
+ deletedFiles.add(filePath);
186
+ }
87
187
  }
88
188
  }
89
189
  for (const filePath of before.untrackedFiles) {
@@ -101,8 +201,26 @@ export async function restoreRollbackBoundary(input) {
101
201
  deletedFiles.add(snapshot.path);
102
202
  }
103
203
  }
104
- const after = readRepoState(input.repoRoot);
105
- const restored = repoStateMatchesBoundary(after, input.boundary);
204
+ for (const filePath of removePaths) {
205
+ await removeRepoPath(input.repoRoot, filePath);
206
+ deletedFiles.add(filePath);
207
+ }
208
+ const ignoredSnapshots = ignoredContentSnapshots.get(input.boundary);
209
+ for (const filePath of restoreIgnoredPaths) {
210
+ const contents = ignoredSnapshots?.get(filePath);
211
+ if (!contents) {
212
+ throw new Error(`Ignored-file rollback snapshot is unavailable for ${filePath}`);
213
+ }
214
+ const absolutePath = resolveRepoPath(input.repoRoot, filePath);
215
+ await mkdir(dirname(absolutePath), { recursive: true });
216
+ resolveRepoPath(input.repoRoot, filePath);
217
+ await writeFile(absolutePath, contents);
218
+ restoredFiles.add(filePath);
219
+ }
220
+ const after = readRepoState(input.repoRoot, input.boundary.headRef);
221
+ const restored = repoStateMatchesBoundary(after, input.boundary) &&
222
+ (input.boundary.headRef === undefined ||
223
+ readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]) === input.boundary.headRef);
106
224
  return {
107
225
  attempted: true,
108
226
  status: restored ? "restored" : "failed",
@@ -112,13 +230,14 @@ export async function restoreRollbackBoundary(input) {
112
230
  after,
113
231
  restoredFiles: [...restoredFiles].sort(),
114
232
  deletedFiles: [...deletedFiles].sort(),
233
+ ...(rewoundHead ? { rewoundHead } : {}),
115
234
  ...(restored
116
235
  ? {}
117
236
  : { error: "Repo state still diverged from the recorded rollback boundary after restore." })
118
237
  };
119
238
  }
120
239
  catch (error) {
121
- const after = readRepoState(input.repoRoot);
240
+ const after = readRepoState(input.repoRoot, input.boundary.headRef);
122
241
  return {
123
242
  attempted: true,
124
243
  status: "failed",
@@ -132,18 +251,237 @@ export async function restoreRollbackBoundary(input) {
132
251
  };
133
252
  }
134
253
  }
135
- function readRepoState(repoRoot) {
254
+ function readRepoState(repoRoot, baseRef = "HEAD") {
136
255
  return {
137
- trackedDirtyFiles: readGitLines(repoRoot, ["diff", "--name-only", "HEAD", "--", "."]),
138
- untrackedFiles: readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."])
256
+ trackedDirtyFiles: readGitLines(repoRoot, ["diff", "--name-only", baseRef, "--", "."]),
257
+ // Git for Windows lists untracked files *through* junctions, so a link to a
258
+ // directory outside the repo would otherwise surface that directory's files as
259
+ // repo paths. Collapse every such path to the link itself.
260
+ untrackedFiles: collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."]))
139
261
  };
140
262
  }
263
+ /**
264
+ * Repo-relative links (symlinks or junctions) among `paths` whose target
265
+ * resolves outside the repository. Paths are first collapsed to the link.
266
+ */
267
+ export function findEscapingLinks(repoRoot, paths) {
268
+ const realRoot = realpathSync.native(resolve(repoRoot));
269
+ return collapsePathsThroughLinks(repoRoot, paths).filter((filePath) => {
270
+ if (!isLinkPath(repoRoot, filePath)) {
271
+ return false;
272
+ }
273
+ try {
274
+ const target = realpathSync.native(resolve(repoRoot, filePath));
275
+ const fromRoot = relative(realRoot, target);
276
+ return fromRoot === ".." || fromRoot.startsWith("../") || fromRoot.startsWith("..\\") || isAbsolute(fromRoot);
277
+ }
278
+ catch {
279
+ // A dangling link cannot be proven to stay inside the repository.
280
+ return true;
281
+ }
282
+ });
283
+ }
284
+ function collapsePathsThroughLinks(repoRoot, paths) {
285
+ const linkCache = new Map();
286
+ const collapsed = paths.map((filePath) => {
287
+ const segments = normalizeRepoPath(filePath).replace(/\/$/u, "").split("/").filter(Boolean);
288
+ for (let index = 1; index <= segments.length; index++) {
289
+ const prefix = segments.slice(0, index).join("/");
290
+ let isLink = linkCache.get(prefix);
291
+ if (isLink === undefined) {
292
+ isLink = isLinkPath(repoRoot, prefix);
293
+ linkCache.set(prefix, isLink);
294
+ }
295
+ if (isLink) {
296
+ return prefix;
297
+ }
298
+ }
299
+ return filePath;
300
+ });
301
+ return uniqueSorted(collapsed);
302
+ }
303
+ function isLinkPath(repoRoot, filePath) {
304
+ try {
305
+ return lstatSync(resolve(repoRoot, filePath)).isSymbolicLink();
306
+ }
307
+ catch (error) {
308
+ if (isFileNotFoundError(error))
309
+ return false;
310
+ throw new RollbackStateUnavailableError(`Unable to authenticate rollback path ${normalizeRepoPath(filePath)}: ${toErrorMessage(error)}`);
311
+ }
312
+ }
313
+ /** Throws when any directory between the repo root and `filePath` is a link. */
314
+ function assertNoIntermediateLink(repoRoot, filePath) {
315
+ const segments = normalizeRepoPath(filePath).split("/").filter(Boolean);
316
+ for (let index = 1; index < segments.length; index++) {
317
+ const prefix = segments.slice(0, index).join("/");
318
+ if (isLinkPath(repoRoot, prefix)) {
319
+ throw new Error(`Refusing to access a rollback path through a link: ${filePath} (via ${prefix})`);
320
+ }
321
+ }
322
+ }
323
+ /**
324
+ * Moves the boundary branch back to the boundary commit, keeping the attempt's
325
+ * committed content in the index so the normal restore pass can undo it.
326
+ */
327
+ function rewindAttemptCommits(repoRoot, boundary, currentHead) {
328
+ const currentBranch = readHeadBranch(repoRoot);
329
+ if (currentBranch !== boundary.headBranch) {
330
+ throw new Error(`Attempt switched HEAD from ${boundary.headBranch ?? "a detached HEAD"} to ` +
331
+ `${currentBranch ?? "a detached HEAD"} (now ${currentHead}); restore ${String(boundary.headRef)} manually.`);
332
+ }
333
+ const result = spawnSync("git", ["reset", "--soft", String(boundary.headRef)], {
334
+ cwd: repoRoot,
335
+ encoding: "utf8"
336
+ });
337
+ if (result.status !== 0) {
338
+ throw new Error(`git reset --soft ${String(boundary.headRef)} failed: ${gitFailureMessage(result)}`);
339
+ }
340
+ }
341
+ function readHeadBranch(repoRoot) {
342
+ const result = spawnSync("git", ["symbolic-ref", "-q", "HEAD"], {
343
+ cwd: repoRoot,
344
+ encoding: "utf8"
345
+ });
346
+ const value = result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim() : "";
347
+ return value.length > 0 ? value : undefined;
348
+ }
349
+ function existsInHead(repoRoot, filePath) {
350
+ resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
351
+ return spawnSync("git", ["cat-file", "-e", `HEAD:${filePath}`], { cwd: repoRoot }).status === 0;
352
+ }
353
+ function unstagePath(repoRoot, filePath) {
354
+ resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
355
+ const result = spawnSync("git", ["rm", "--cached", "-r", "-q", "-f", "--ignore-unmatch", "--", filePath], { cwd: repoRoot, encoding: "utf8" });
356
+ if (result.status !== 0) {
357
+ throw new Error(`git rm --cached failed for ${filePath}: ${gitFailureMessage(result)}`);
358
+ }
359
+ }
360
+ function readIgnoredInventory(repoRoot) {
361
+ const collapsed = collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, [
362
+ "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--", "."
363
+ ]));
364
+ const inventory = [];
365
+ let expanded = 0;
366
+ const hashBudget = { remainingBytes: MAX_HASHED_IGNORED_TOTAL_BYTES };
367
+ for (const entry of collapsed) {
368
+ const isDirectory = entry.endsWith("/");
369
+ const leaf = entry.replace(/\/$/u, "").split("/").at(-1) ?? entry;
370
+ if (isDirectory && DEPENDENCY_AND_BUILD_DIRECTORIES.has(leaf)) {
371
+ inventory.push({ path: entry, size: 0, mtimeMs: 0, ctimeMs: 0 });
372
+ continue;
373
+ }
374
+ if (isDirectory && expanded >= MAX_EXPANDED_IGNORED_FILES) {
375
+ throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
376
+ }
377
+ const files = isDirectory
378
+ ? collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--", entry]))
379
+ : [entry];
380
+ for (const filePath of files) {
381
+ if (expanded >= MAX_EXPANDED_IGNORED_FILES) {
382
+ throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
383
+ }
384
+ inventory.push(stampIgnoredFile(repoRoot, filePath, hashBudget));
385
+ expanded += 1;
386
+ }
387
+ }
388
+ return inventory.sort((left, right) => left.path.localeCompare(right.path));
389
+ }
390
+ function stampIgnoredFile(repoRoot, filePath, hashBudget) {
391
+ try {
392
+ const absolutePath = resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
393
+ const link = isLinkPath(repoRoot, filePath);
394
+ const stats = link ? lstatSync(absolutePath) : statSync(absolutePath);
395
+ const stamp = {
396
+ path: normalizeRepoPath(filePath),
397
+ size: stats.size,
398
+ mtimeMs: Math.trunc(stats.mtimeMs),
399
+ ctimeMs: Math.trunc(stats.ctimeMs)
400
+ };
401
+ if (!link &&
402
+ stats.isFile() &&
403
+ stats.size <= MAX_HASHED_IGNORED_FILE_BYTES &&
404
+ stats.size <= hashBudget.remainingBytes &&
405
+ !isInsideDependencyOrBuildDirectory(filePath)) {
406
+ stamp.sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
407
+ hashBudget.remainingBytes -= stats.size;
408
+ }
409
+ return stamp;
410
+ }
411
+ catch (error) {
412
+ if (!isFileNotFoundError(error)) {
413
+ throw new RollbackStateUnavailableError(`Unable to authenticate ignored rollback path ${normalizeRepoPath(filePath)}: ${toErrorMessage(error)}`);
414
+ }
415
+ // Vanished between listing and stat; record presence only so later comparison fails closed.
416
+ return { path: normalizeRepoPath(filePath), size: -1, mtimeMs: -1, ctimeMs: -1 };
417
+ }
418
+ }
419
+ function captureIgnoredContent(repoRoot, ignoredFiles) {
420
+ const snapshots = new Map();
421
+ let capturedBytes = 0;
422
+ for (const entry of ignoredFiles) {
423
+ if (entry.path.endsWith("/") ||
424
+ isInsideDependencyOrBuildDirectory(entry.path) ||
425
+ isLinkPath(repoRoot, entry.path)) {
426
+ continue;
427
+ }
428
+ const absolutePath = resolveRepoPath(repoRoot, entry.path);
429
+ if (entry.size < 0 || capturedBytes + entry.size > MAX_IGNORED_SNAPSHOT_BYTES) {
430
+ continue;
431
+ }
432
+ try {
433
+ const contents = readFileSync(absolutePath);
434
+ capturedBytes += contents.byteLength;
435
+ snapshots.set(entry.path, contents);
436
+ }
437
+ catch (error) {
438
+ if (!isFileNotFoundError(error)) {
439
+ throw new RollbackStateUnavailableError(`Ignored rollback snapshot unavailable for ${entry.path}: ${toErrorMessage(error)}`);
440
+ }
441
+ }
442
+ }
443
+ return snapshots;
444
+ }
445
+ function snapshotChangedPaths(repoRoot, snapshots) {
446
+ const changed = [];
447
+ for (const snapshot of snapshots) {
448
+ try {
449
+ const absolutePath = resolveRepoPath(repoRoot, snapshot.path);
450
+ if (!snapshot.existed || !snapshot.contentBase64) {
451
+ try {
452
+ readFileSync(absolutePath);
453
+ changed.push(snapshot.path);
454
+ }
455
+ catch (error) {
456
+ if (!isFileNotFoundError(error))
457
+ throw error;
458
+ }
459
+ continue;
460
+ }
461
+ const current = readFileSync(absolutePath);
462
+ if (!current.equals(Buffer.from(snapshot.contentBase64, "base64"))) {
463
+ changed.push(snapshot.path);
464
+ }
465
+ }
466
+ catch (error) {
467
+ // A missing file or link swap is still an attempt change. It must flow to
468
+ // the leash and rollback path, which will authenticate before mutation.
469
+ if (isFileNotFoundError(error) || error instanceof RollbackStateUnavailableError || error instanceof Error) {
470
+ changed.push(snapshot.path);
471
+ continue;
472
+ }
473
+ throw error;
474
+ }
475
+ }
476
+ return uniqueSorted(changed);
477
+ }
141
478
  function readGitLines(repoRoot, args) {
142
479
  let lastFailure = "unknown Git failure";
143
480
  for (let attempt = 0; attempt < 2; attempt++) {
144
481
  const result = spawnSync("git", args, {
145
482
  cwd: repoRoot,
146
- encoding: "utf8"
483
+ encoding: "utf8",
484
+ maxBuffer: GIT_LIST_MAX_BUFFER_BYTES
147
485
  });
148
486
  if (result.status === 0 && typeof result.stdout === "string") {
149
487
  return uniqueSorted(result.stdout
@@ -193,16 +531,50 @@ async function readRollbackSnapshot(repoRoot, filePath) {
193
531
  };
194
532
  }
195
533
  }
534
+ async function rollbackSnapshotsMatch(repoRoot, snapshots) {
535
+ for (const snapshot of snapshots) {
536
+ try {
537
+ const absolutePath = resolveRepoPath(repoRoot, snapshot.path);
538
+ if (!snapshot.existed) {
539
+ try {
540
+ await readFile(absolutePath);
541
+ return false;
542
+ }
543
+ catch (error) {
544
+ if (!isFileNotFoundError(error))
545
+ return false;
546
+ }
547
+ continue;
548
+ }
549
+ if (!snapshot.contentBase64)
550
+ return false;
551
+ const current = await readFile(absolutePath);
552
+ if (!current.equals(Buffer.from(snapshot.contentBase64, "base64")))
553
+ return false;
554
+ }
555
+ catch {
556
+ // A link swap, inaccessible path, or vanished baseline means the boundary
557
+ // cannot be declared unchanged; the restore path will fail closed.
558
+ return false;
559
+ }
560
+ }
561
+ return true;
562
+ }
196
563
  async function restoreRollbackSnapshot(repoRoot, snapshot) {
197
564
  const absolutePath = resolveRepoPath(repoRoot, snapshot.path);
198
565
  if (!snapshot.existed || !snapshot.contentBase64) {
566
+ resolveRepoPath(repoRoot, snapshot.path);
199
567
  await rm(absolutePath, { recursive: true, force: true });
200
568
  return;
201
569
  }
202
570
  await mkdir(dirname(absolutePath), { recursive: true });
571
+ resolveRepoPath(repoRoot, snapshot.path);
203
572
  await writeFile(absolutePath, Buffer.from(snapshot.contentBase64, "base64"));
204
573
  }
205
574
  function restoreTrackedFileFromHead(repoRoot, filePath) {
575
+ // Git must never receive a path that crosses a link. A tracked symlink may
576
+ // itself be the leaf, because Git replaces the link rather than traversing it.
577
+ resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
206
578
  const result = spawnSync("git", ["restore", "--staged", "--worktree", "--source=HEAD", "--", filePath], {
207
579
  cwd: repoRoot,
208
580
  encoding: "utf8"
@@ -225,15 +597,26 @@ function restoreTrackedFileFromHead(repoRoot, filePath) {
225
597
  `git restore failed for ${filePath} (retry and checkout fallback also failed)`);
226
598
  }
227
599
  async function removeRepoPath(repoRoot, filePath) {
228
- await rm(resolveRepoPath(repoRoot, filePath), { recursive: true, force: true });
600
+ const absolutePath = resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
601
+ if (isLinkPath(repoRoot, filePath)) {
602
+ // Remove the link itself; never descend into whatever it points at.
603
+ await rm(absolutePath, { force: true });
604
+ return;
605
+ }
606
+ resolveRepoPath(repoRoot, filePath);
607
+ await rm(absolutePath, { recursive: true, force: true });
229
608
  }
230
- function resolveRepoPath(repoRoot, filePath) {
609
+ function resolveRepoPath(repoRoot, filePath, options = {}) {
231
610
  const resolvedRoot = resolve(repoRoot);
232
611
  const resolvedPath = resolve(resolvedRoot, filePath);
233
612
  const relativePath = relative(resolvedRoot, resolvedPath);
234
613
  if (relativePath.startsWith("..") || relativePath === "") {
235
614
  throw new Error(`Refusing to access a rollback path outside repo root: ${filePath}`);
236
615
  }
616
+ assertNoIntermediateLink(resolvedRoot, relativePath);
617
+ if (!options.allowLeafLink && isLinkPath(resolvedRoot, relativePath)) {
618
+ throw new Error(`Refusing to access a rollback path that is a link: ${filePath}`);
619
+ }
237
620
  return resolvedPath;
238
621
  }
239
622
  function repoStateMatchesBoundary(state, boundary) {
@@ -62,3 +62,21 @@ export declare function importsPreExistingCode(testContent: string, preRunSource
62
62
  * Validate the integrity of tests created during a governed run.
63
63
  */
64
64
  export declare function validateTestIntegrity(rootDir: string, preRunTestSnapshot: Set<string>, preRunSourceFiles: Set<string>): TestIntegrityReport;
65
+ /**
66
+ * Repo-relative path check for test sources, covering JS/TS naming, test
67
+ * directories at any depth (including the repo root), Python, and Go.
68
+ */
69
+ export declare function isTestPath(filePath: string): boolean;
70
+ /**
71
+ * Existing test files whose assertions the attempt removed or altered, whose cases it
72
+ * dropped or skipped, or which it deleted, compared with the boundary commit. New test
73
+ * files, appended cases, and edits to imports, setup or formatting are not reported.
74
+ * Files with no recognizable test surface (data, golden and snapshot files, unknown
75
+ * frameworks) fall back to the strict rule: any removed line counts. Changes committed
76
+ * by the attempt are included because the comparison is against `baseRef`, not HEAD.
77
+ */
78
+ export declare function findWeakenedTestFiles(input: {
79
+ repoRoot: string;
80
+ baseRef: string;
81
+ changedFiles: string[];
82
+ }): string[];