@martinloop/mcp 0.8.6 → 0.8.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.
@@ -1,6 +1,15 @@
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_IGNORED_SNAPSHOT_BYTES = 64 * 1024 * 1024;
10
+ const ignoredContentSnapshots = new WeakMap();
11
+ /** Git listings of large untracked or ignored trees exceed spawnSync's 1 MiB default. */
12
+ const GIT_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
4
13
  export class RollbackStateUnavailableError extends Error {
5
14
  constructor(message) {
6
15
  super(message);
@@ -11,7 +20,9 @@ export function listAttemptChangedFilesSinceBoundary(input) {
11
20
  if (!input.repoRoot) {
12
21
  return [];
13
22
  }
14
- const repoState = readRepoState(input.repoRoot);
23
+ // Diff against the boundary commit (not the live HEAD) so that changes an
24
+ // attempt commits are still reported as attempt changes.
25
+ const repoState = readRepoState(input.repoRoot, input.boundary?.headRef);
15
26
  if (!input.boundary) {
16
27
  return uniqueSorted([...repoState.trackedDirtyFiles, ...repoState.untrackedFiles]);
17
28
  }
@@ -19,7 +30,10 @@ export function listAttemptChangedFilesSinceBoundary(input) {
19
30
  const baselineUntracked = new Set(input.boundary.untrackedFiles);
20
31
  return uniqueSorted([
21
32
  ...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)),
22
- ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath))
33
+ ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)),
34
+ ...(input.includeBaselineSnapshotChanges
35
+ ? snapshotChangedPaths(input.repoRoot, input.boundary.snapshots)
36
+ : [])
23
37
  ]);
24
38
  }
25
39
  export async function captureRollbackBoundary(input) {
@@ -27,23 +41,70 @@ export async function captureRollbackBoundary(input) {
27
41
  return undefined;
28
42
  }
29
43
  const repoState = readRepoState(input.repoRoot);
44
+ // Links are recorded by path only: their targets are never read or restored.
30
45
  const snapshotPaths = uniqueSorted([
31
46
  ...repoState.trackedDirtyFiles,
32
47
  ...repoState.untrackedFiles
33
- ]);
48
+ ]).filter((filePath) => !isLinkPath(input.repoRoot, filePath));
34
49
  const snapshots = [];
35
50
  for (const filePath of snapshotPaths) {
36
51
  snapshots.push(await readRollbackSnapshot(input.repoRoot, filePath));
37
52
  }
53
+ const ignoredFiles = readIgnoredInventory(input.repoRoot);
38
54
  const headRef = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
39
- return {
55
+ const headBranch = readHeadBranch(input.repoRoot);
56
+ const boundary = {
40
57
  strategy: "git_head_plus_snapshot",
41
58
  capturedAt: input.capturedAt,
42
59
  ...(headRef ? { headRef } : {}),
60
+ ...(headBranch ? { headBranch } : {}),
43
61
  trackedDirtyFiles: repoState.trackedDirtyFiles,
44
62
  untrackedFiles: repoState.untrackedFiles,
45
- snapshots
63
+ snapshots,
64
+ ignoredFiles
46
65
  };
66
+ ignoredContentSnapshots.set(boundary, captureIgnoredContent(input.repoRoot, ignoredFiles));
67
+ return boundary;
68
+ }
69
+ /**
70
+ * Git-ignored files the attempt created or modified since the boundary.
71
+ * Boundaries recorded before the ignored inventory existed report nothing.
72
+ */
73
+ export function listIgnoredFileChangesSinceBoundary(input) {
74
+ if (!input.repoRoot || !input.boundary?.ignoredFiles) {
75
+ return makeIgnoredFileChanges([], [], []);
76
+ }
77
+ const baseline = new Map(input.boundary.ignoredFiles.map((entry) => [entry.path, entry]));
78
+ const created = [];
79
+ const modified = [];
80
+ const currentEntries = readIgnoredInventory(input.repoRoot);
81
+ const current = new Map(currentEntries.map((entry) => [entry.path, entry]));
82
+ const deleted = input.boundary.ignoredFiles
83
+ .filter((entry) => !current.has(entry.path))
84
+ .map((entry) => entry.path);
85
+ for (const entry of currentEntries) {
86
+ const previous = baseline.get(entry.path);
87
+ if (!previous) {
88
+ created.push(entry.path);
89
+ }
90
+ else if (!entry.path.endsWith("/") &&
91
+ (previous.sha256 !== undefined && entry.sha256 !== undefined
92
+ ? previous.sha256 !== entry.sha256
93
+ : previous.size !== entry.size || previous.mtimeMs !== entry.mtimeMs)) {
94
+ modified.push(entry.path);
95
+ }
96
+ }
97
+ return makeIgnoredFileChanges(created.sort(), modified.sort(), deleted.sort());
98
+ }
99
+ function makeIgnoredFileChanges(created, modified, deleted) {
100
+ const changes = { created, modified };
101
+ // Preserve the historical enumerable shape when there are no deletions while
102
+ // still exposing deletion evidence to current callers.
103
+ Object.defineProperty(changes, "deleted", {
104
+ value: deleted,
105
+ enumerable: deleted.length > 0
106
+ });
107
+ return changes;
47
108
  }
48
109
  export async function restoreRollbackBoundary(input) {
49
110
  if (!input.repoRoot) {
@@ -62,8 +123,17 @@ export async function restoreRollbackBoundary(input) {
62
123
  error: "Rollback boundary was unavailable for this attempt."
63
124
  };
64
125
  }
65
- const before = readRepoState(input.repoRoot);
66
- if (repoStateMatchesBoundary(before, input.boundary)) {
126
+ const before = readRepoState(input.repoRoot, input.boundary.headRef);
127
+ const currentHead = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
128
+ const headMoved = input.boundary.headRef !== undefined && currentHead !== input.boundary.headRef;
129
+ const removePaths = uniqueSorted(input.removePaths ?? []);
130
+ const restoreIgnoredPaths = uniqueSorted(input.restoreIgnoredPaths ?? []);
131
+ const snapshotsUnchanged = await rollbackSnapshotsMatch(input.repoRoot, input.boundary.snapshots);
132
+ if (!headMoved &&
133
+ removePaths.length === 0 &&
134
+ restoreIgnoredPaths.length === 0 &&
135
+ repoStateMatchesBoundary(before, input.boundary) &&
136
+ snapshotsUnchanged) {
67
137
  return {
68
138
  attempted: false,
69
139
  status: "not_required",
@@ -77,13 +147,25 @@ export async function restoreRollbackBoundary(input) {
77
147
  }
78
148
  const restoredFiles = new Set();
79
149
  const deletedFiles = new Set();
150
+ let rewoundHead;
80
151
  try {
152
+ if (headMoved && input.boundary.headRef && currentHead) {
153
+ rewindAttemptCommits(input.repoRoot, input.boundary, currentHead);
154
+ rewoundHead = { from: currentHead, to: input.boundary.headRef };
155
+ }
81
156
  const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
82
157
  const baselineUntracked = new Set(input.boundary.untrackedFiles);
83
158
  for (const filePath of before.trackedDirtyFiles) {
84
159
  if (!baselineTracked.has(filePath)) {
85
- restoreTrackedFileFromHead(input.repoRoot, filePath);
86
- restoredFiles.add(filePath);
160
+ if (existsInHead(input.repoRoot, filePath)) {
161
+ restoreTrackedFileFromHead(input.repoRoot, filePath);
162
+ restoredFiles.add(filePath);
163
+ }
164
+ else {
165
+ unstagePath(input.repoRoot, filePath);
166
+ await removeRepoPath(input.repoRoot, filePath);
167
+ deletedFiles.add(filePath);
168
+ }
87
169
  }
88
170
  }
89
171
  for (const filePath of before.untrackedFiles) {
@@ -101,8 +183,26 @@ export async function restoreRollbackBoundary(input) {
101
183
  deletedFiles.add(snapshot.path);
102
184
  }
103
185
  }
104
- const after = readRepoState(input.repoRoot);
105
- const restored = repoStateMatchesBoundary(after, input.boundary);
186
+ for (const filePath of removePaths) {
187
+ await removeRepoPath(input.repoRoot, filePath);
188
+ deletedFiles.add(filePath);
189
+ }
190
+ const ignoredSnapshots = ignoredContentSnapshots.get(input.boundary);
191
+ for (const filePath of restoreIgnoredPaths) {
192
+ const contents = ignoredSnapshots?.get(filePath);
193
+ if (!contents) {
194
+ throw new Error(`Ignored-file rollback snapshot is unavailable for ${filePath}`);
195
+ }
196
+ const absolutePath = resolveRepoPath(input.repoRoot, filePath);
197
+ await mkdir(dirname(absolutePath), { recursive: true });
198
+ resolveRepoPath(input.repoRoot, filePath);
199
+ await writeFile(absolutePath, contents);
200
+ restoredFiles.add(filePath);
201
+ }
202
+ const after = readRepoState(input.repoRoot, input.boundary.headRef);
203
+ const restored = repoStateMatchesBoundary(after, input.boundary) &&
204
+ (input.boundary.headRef === undefined ||
205
+ readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]) === input.boundary.headRef);
106
206
  return {
107
207
  attempted: true,
108
208
  status: restored ? "restored" : "failed",
@@ -112,13 +212,14 @@ export async function restoreRollbackBoundary(input) {
112
212
  after,
113
213
  restoredFiles: [...restoredFiles].sort(),
114
214
  deletedFiles: [...deletedFiles].sort(),
215
+ ...(rewoundHead ? { rewoundHead } : {}),
115
216
  ...(restored
116
217
  ? {}
117
218
  : { error: "Repo state still diverged from the recorded rollback boundary after restore." })
118
219
  };
119
220
  }
120
221
  catch (error) {
121
- const after = readRepoState(input.repoRoot);
222
+ const after = readRepoState(input.repoRoot, input.boundary.headRef);
122
223
  return {
123
224
  attempted: true,
124
225
  status: "failed",
@@ -132,18 +233,225 @@ export async function restoreRollbackBoundary(input) {
132
233
  };
133
234
  }
134
235
  }
135
- function readRepoState(repoRoot) {
236
+ function readRepoState(repoRoot, baseRef = "HEAD") {
136
237
  return {
137
- trackedDirtyFiles: readGitLines(repoRoot, ["diff", "--name-only", "HEAD", "--", "."]),
138
- untrackedFiles: readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."])
238
+ trackedDirtyFiles: readGitLines(repoRoot, ["diff", "--name-only", baseRef, "--", "."]),
239
+ // Git for Windows lists untracked files *through* junctions, so a link to a
240
+ // directory outside the repo would otherwise surface that directory's files as
241
+ // repo paths. Collapse every such path to the link itself.
242
+ untrackedFiles: collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."]))
139
243
  };
140
244
  }
245
+ /**
246
+ * Repo-relative links (symlinks or junctions) among `paths` whose target
247
+ * resolves outside the repository. Paths are first collapsed to the link.
248
+ */
249
+ export function findEscapingLinks(repoRoot, paths) {
250
+ const realRoot = realpathSync.native(resolve(repoRoot));
251
+ return collapsePathsThroughLinks(repoRoot, paths).filter((filePath) => {
252
+ if (!isLinkPath(repoRoot, filePath)) {
253
+ return false;
254
+ }
255
+ try {
256
+ const target = realpathSync.native(resolve(repoRoot, filePath));
257
+ const fromRoot = relative(realRoot, target);
258
+ return fromRoot === ".." || fromRoot.startsWith("../") || fromRoot.startsWith("..\\") || isAbsolute(fromRoot);
259
+ }
260
+ catch {
261
+ // A dangling link cannot be proven to stay inside the repository.
262
+ return true;
263
+ }
264
+ });
265
+ }
266
+ function collapsePathsThroughLinks(repoRoot, paths) {
267
+ const linkCache = new Map();
268
+ const collapsed = paths.map((filePath) => {
269
+ const segments = normalizeRepoPath(filePath).replace(/\/$/u, "").split("/").filter(Boolean);
270
+ for (let index = 1; index <= segments.length; index++) {
271
+ const prefix = segments.slice(0, index).join("/");
272
+ let isLink = linkCache.get(prefix);
273
+ if (isLink === undefined) {
274
+ isLink = isLinkPath(repoRoot, prefix);
275
+ linkCache.set(prefix, isLink);
276
+ }
277
+ if (isLink) {
278
+ return prefix;
279
+ }
280
+ }
281
+ return filePath;
282
+ });
283
+ return uniqueSorted(collapsed);
284
+ }
285
+ function isLinkPath(repoRoot, filePath) {
286
+ try {
287
+ return lstatSync(resolve(repoRoot, filePath)).isSymbolicLink();
288
+ }
289
+ catch (error) {
290
+ if (isFileNotFoundError(error))
291
+ return false;
292
+ throw new RollbackStateUnavailableError(`Unable to authenticate rollback path ${normalizeRepoPath(filePath)}: ${toErrorMessage(error)}`);
293
+ }
294
+ }
295
+ /** Throws when any directory between the repo root and `filePath` is a link. */
296
+ function assertNoIntermediateLink(repoRoot, filePath) {
297
+ const segments = normalizeRepoPath(filePath).split("/").filter(Boolean);
298
+ for (let index = 1; index < segments.length; index++) {
299
+ const prefix = segments.slice(0, index).join("/");
300
+ if (isLinkPath(repoRoot, prefix)) {
301
+ throw new Error(`Refusing to access a rollback path through a link: ${filePath} (via ${prefix})`);
302
+ }
303
+ }
304
+ }
305
+ /**
306
+ * Moves the boundary branch back to the boundary commit, keeping the attempt's
307
+ * committed content in the index so the normal restore pass can undo it.
308
+ */
309
+ function rewindAttemptCommits(repoRoot, boundary, currentHead) {
310
+ const currentBranch = readHeadBranch(repoRoot);
311
+ if (currentBranch !== boundary.headBranch) {
312
+ throw new Error(`Attempt switched HEAD from ${boundary.headBranch ?? "a detached HEAD"} to ` +
313
+ `${currentBranch ?? "a detached HEAD"} (now ${currentHead}); restore ${String(boundary.headRef)} manually.`);
314
+ }
315
+ const result = spawnSync("git", ["reset", "--soft", String(boundary.headRef)], {
316
+ cwd: repoRoot,
317
+ encoding: "utf8"
318
+ });
319
+ if (result.status !== 0) {
320
+ throw new Error(`git reset --soft ${String(boundary.headRef)} failed: ${gitFailureMessage(result)}`);
321
+ }
322
+ }
323
+ function readHeadBranch(repoRoot) {
324
+ const result = spawnSync("git", ["symbolic-ref", "-q", "HEAD"], {
325
+ cwd: repoRoot,
326
+ encoding: "utf8"
327
+ });
328
+ const value = result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim() : "";
329
+ return value.length > 0 ? value : undefined;
330
+ }
331
+ function existsInHead(repoRoot, filePath) {
332
+ resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
333
+ return spawnSync("git", ["cat-file", "-e", `HEAD:${filePath}`], { cwd: repoRoot }).status === 0;
334
+ }
335
+ function unstagePath(repoRoot, filePath) {
336
+ resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
337
+ const result = spawnSync("git", ["rm", "--cached", "-r", "-q", "-f", "--ignore-unmatch", "--", filePath], { cwd: repoRoot, encoding: "utf8" });
338
+ if (result.status !== 0) {
339
+ throw new Error(`git rm --cached failed for ${filePath}: ${gitFailureMessage(result)}`);
340
+ }
341
+ }
342
+ function readIgnoredInventory(repoRoot) {
343
+ const collapsed = collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, [
344
+ "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--", "."
345
+ ]));
346
+ const inventory = [];
347
+ let expanded = 0;
348
+ for (const entry of collapsed) {
349
+ const isDirectory = entry.endsWith("/");
350
+ const leaf = entry.replace(/\/$/u, "").split("/").at(-1) ?? entry;
351
+ if (isDirectory &&
352
+ (DEPENDENCY_AND_BUILD_DIRECTORIES.has(leaf) || expanded >= MAX_EXPANDED_IGNORED_FILES)) {
353
+ inventory.push({ path: entry, size: 0, mtimeMs: 0 });
354
+ continue;
355
+ }
356
+ const files = isDirectory
357
+ ? collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--", entry]))
358
+ : [entry];
359
+ for (const filePath of files) {
360
+ inventory.push(stampIgnoredFile(repoRoot, filePath));
361
+ expanded += 1;
362
+ }
363
+ }
364
+ return inventory.sort((left, right) => left.path.localeCompare(right.path));
365
+ }
366
+ function stampIgnoredFile(repoRoot, filePath) {
367
+ try {
368
+ const absolutePath = resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
369
+ const link = isLinkPath(repoRoot, filePath);
370
+ const stats = link ? lstatSync(absolutePath) : statSync(absolutePath);
371
+ const stamp = {
372
+ path: normalizeRepoPath(filePath),
373
+ size: stats.size,
374
+ mtimeMs: Math.trunc(stats.mtimeMs)
375
+ };
376
+ if (!link && stats.isFile() && !isInsideDependencyOrBuildDirectory(filePath)) {
377
+ stamp.sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
378
+ }
379
+ return stamp;
380
+ }
381
+ catch (error) {
382
+ if (!isFileNotFoundError(error)) {
383
+ throw new RollbackStateUnavailableError(`Unable to authenticate ignored rollback path ${normalizeRepoPath(filePath)}: ${toErrorMessage(error)}`);
384
+ }
385
+ // Vanished between listing and stat; record presence only so later comparison fails closed.
386
+ return { path: normalizeRepoPath(filePath), size: -1, mtimeMs: -1 };
387
+ }
388
+ }
389
+ function captureIgnoredContent(repoRoot, ignoredFiles) {
390
+ const snapshots = new Map();
391
+ let capturedBytes = 0;
392
+ for (const entry of ignoredFiles) {
393
+ if (entry.path.endsWith("/") ||
394
+ isInsideDependencyOrBuildDirectory(entry.path) ||
395
+ isLinkPath(repoRoot, entry.path)) {
396
+ continue;
397
+ }
398
+ const absolutePath = resolveRepoPath(repoRoot, entry.path);
399
+ if (entry.size < 0 || capturedBytes + entry.size > MAX_IGNORED_SNAPSHOT_BYTES) {
400
+ continue;
401
+ }
402
+ try {
403
+ const contents = readFileSync(absolutePath);
404
+ capturedBytes += contents.byteLength;
405
+ snapshots.set(entry.path, contents);
406
+ }
407
+ catch (error) {
408
+ if (!isFileNotFoundError(error)) {
409
+ throw new RollbackStateUnavailableError(`Ignored rollback snapshot unavailable for ${entry.path}: ${toErrorMessage(error)}`);
410
+ }
411
+ }
412
+ }
413
+ return snapshots;
414
+ }
415
+ function snapshotChangedPaths(repoRoot, snapshots) {
416
+ const changed = [];
417
+ for (const snapshot of snapshots) {
418
+ try {
419
+ const absolutePath = resolveRepoPath(repoRoot, snapshot.path);
420
+ if (!snapshot.existed || !snapshot.contentBase64) {
421
+ try {
422
+ readFileSync(absolutePath);
423
+ changed.push(snapshot.path);
424
+ }
425
+ catch (error) {
426
+ if (!isFileNotFoundError(error))
427
+ throw error;
428
+ }
429
+ continue;
430
+ }
431
+ const current = readFileSync(absolutePath);
432
+ if (!current.equals(Buffer.from(snapshot.contentBase64, "base64"))) {
433
+ changed.push(snapshot.path);
434
+ }
435
+ }
436
+ catch (error) {
437
+ // A missing file or link swap is still an attempt change. It must flow to
438
+ // the leash and rollback path, which will authenticate before mutation.
439
+ if (isFileNotFoundError(error) || error instanceof RollbackStateUnavailableError || error instanceof Error) {
440
+ changed.push(snapshot.path);
441
+ continue;
442
+ }
443
+ throw error;
444
+ }
445
+ }
446
+ return uniqueSorted(changed);
447
+ }
141
448
  function readGitLines(repoRoot, args) {
142
449
  let lastFailure = "unknown Git failure";
143
450
  for (let attempt = 0; attempt < 2; attempt++) {
144
451
  const result = spawnSync("git", args, {
145
452
  cwd: repoRoot,
146
- encoding: "utf8"
453
+ encoding: "utf8",
454
+ maxBuffer: GIT_LIST_MAX_BUFFER_BYTES
147
455
  });
148
456
  if (result.status === 0 && typeof result.stdout === "string") {
149
457
  return uniqueSorted(result.stdout
@@ -193,16 +501,50 @@ async function readRollbackSnapshot(repoRoot, filePath) {
193
501
  };
194
502
  }
195
503
  }
504
+ async function rollbackSnapshotsMatch(repoRoot, snapshots) {
505
+ for (const snapshot of snapshots) {
506
+ try {
507
+ const absolutePath = resolveRepoPath(repoRoot, snapshot.path);
508
+ if (!snapshot.existed) {
509
+ try {
510
+ await readFile(absolutePath);
511
+ return false;
512
+ }
513
+ catch (error) {
514
+ if (!isFileNotFoundError(error))
515
+ return false;
516
+ }
517
+ continue;
518
+ }
519
+ if (!snapshot.contentBase64)
520
+ return false;
521
+ const current = await readFile(absolutePath);
522
+ if (!current.equals(Buffer.from(snapshot.contentBase64, "base64")))
523
+ return false;
524
+ }
525
+ catch {
526
+ // A link swap, inaccessible path, or vanished baseline means the boundary
527
+ // cannot be declared unchanged; the restore path will fail closed.
528
+ return false;
529
+ }
530
+ }
531
+ return true;
532
+ }
196
533
  async function restoreRollbackSnapshot(repoRoot, snapshot) {
197
534
  const absolutePath = resolveRepoPath(repoRoot, snapshot.path);
198
535
  if (!snapshot.existed || !snapshot.contentBase64) {
536
+ resolveRepoPath(repoRoot, snapshot.path);
199
537
  await rm(absolutePath, { recursive: true, force: true });
200
538
  return;
201
539
  }
202
540
  await mkdir(dirname(absolutePath), { recursive: true });
541
+ resolveRepoPath(repoRoot, snapshot.path);
203
542
  await writeFile(absolutePath, Buffer.from(snapshot.contentBase64, "base64"));
204
543
  }
205
544
  function restoreTrackedFileFromHead(repoRoot, filePath) {
545
+ // Git must never receive a path that crosses a link. A tracked symlink may
546
+ // itself be the leaf, because Git replaces the link rather than traversing it.
547
+ resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
206
548
  const result = spawnSync("git", ["restore", "--staged", "--worktree", "--source=HEAD", "--", filePath], {
207
549
  cwd: repoRoot,
208
550
  encoding: "utf8"
@@ -225,15 +567,26 @@ function restoreTrackedFileFromHead(repoRoot, filePath) {
225
567
  `git restore failed for ${filePath} (retry and checkout fallback also failed)`);
226
568
  }
227
569
  async function removeRepoPath(repoRoot, filePath) {
228
- await rm(resolveRepoPath(repoRoot, filePath), { recursive: true, force: true });
570
+ const absolutePath = resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
571
+ if (isLinkPath(repoRoot, filePath)) {
572
+ // Remove the link itself; never descend into whatever it points at.
573
+ await rm(absolutePath, { force: true });
574
+ return;
575
+ }
576
+ resolveRepoPath(repoRoot, filePath);
577
+ await rm(absolutePath, { recursive: true, force: true });
229
578
  }
230
- function resolveRepoPath(repoRoot, filePath) {
579
+ function resolveRepoPath(repoRoot, filePath, options = {}) {
231
580
  const resolvedRoot = resolve(repoRoot);
232
581
  const resolvedPath = resolve(resolvedRoot, filePath);
233
582
  const relativePath = relative(resolvedRoot, resolvedPath);
234
583
  if (relativePath.startsWith("..") || relativePath === "") {
235
584
  throw new Error(`Refusing to access a rollback path outside repo root: ${filePath}`);
236
585
  }
586
+ assertNoIntermediateLink(resolvedRoot, relativePath);
587
+ if (!options.allowLeafLink && isLinkPath(resolvedRoot, relativePath)) {
588
+ throw new Error(`Refusing to access a rollback path that is a link: ${filePath}`);
589
+ }
237
590
  return resolvedPath;
238
591
  }
239
592
  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[];
@@ -16,8 +16,10 @@
16
16
  * This module provides the analysis primitives. The verification pipeline
17
17
  * calls these during the post-run verification step.
18
18
  */
19
- import { readFileSync, readdirSync, existsSync } from "node:fs";
19
+ import { spawnSync } from "node:child_process";
20
+ import { lstatSync, readFileSync, readdirSync, existsSync } from "node:fs";
20
21
  import { join, relative } from "node:path";
22
+ import { extractTestSurface, hasTestSurface, testSurfaceWeakened } from "./test-surface.js";
21
23
  const TEST_FILE_PATTERNS = [
22
24
  /\.test\.[tj]sx?$/,
23
25
  /\.spec\.[tj]sx?$/,
@@ -195,3 +197,91 @@ export function validateTestIntegrity(rootDir, preRunTestSnapshot, preRunSourceF
195
197
  summary
196
198
  };
197
199
  }
200
+ const TEST_PATH_PATTERNS = [
201
+ /\.(?:test|spec)\.[cm]?[jt]sx?$/u,
202
+ /(?:^|\/)(?:__tests__|tests?)\//u,
203
+ /(?:^|\/)test_[^/]+\.py$/u,
204
+ /_test\.(?:py|go)$/u
205
+ ];
206
+ /**
207
+ * Repo-relative path check for test sources, covering JS/TS naming, test
208
+ * directories at any depth (including the repo root), Python, and Go.
209
+ */
210
+ export function isTestPath(filePath) {
211
+ const normalized = filePath.replace(/\\/gu, "/").replace(/^\.\//u, "");
212
+ return TEST_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
213
+ }
214
+ /**
215
+ * Existing test files whose assertions the attempt removed or altered, whose cases it
216
+ * dropped or skipped, or which it deleted, compared with the boundary commit. New test
217
+ * files, appended cases, and edits to imports, setup or formatting are not reported.
218
+ * Files with no recognizable test surface (data, golden and snapshot files, unknown
219
+ * frameworks) fall back to the strict rule: any removed line counts. Changes committed
220
+ * by the attempt are included because the comparison is against `baseRef`, not HEAD.
221
+ */
222
+ export function findWeakenedTestFiles(input) {
223
+ const candidates = [...new Set(input.changedFiles.map((file) => file.replace(/\\/gu, "/")))]
224
+ .filter(isTestPath)
225
+ .sort();
226
+ if (candidates.length === 0) {
227
+ return [];
228
+ }
229
+ const removedLines = listFilesWithRemovedLines(input.repoRoot, input.baseRef, candidates);
230
+ return [...removedLines].filter((filePath) => isWeakened(input, filePath)).sort();
231
+ }
232
+ /** Files among `candidates` that lost at least one line (or are binary) since `baseRef`. */
233
+ function listFilesWithRemovedLines(repoRoot, baseRef, candidates) {
234
+ const result = spawnSync("git", ["diff", "--numstat", "--no-renames", "-z", baseRef, "--", ...candidates], { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
235
+ if (result.status !== 0 || typeof result.stdout !== "string") {
236
+ const detail = typeof result.stderr === "string" ? result.stderr.trim() : "";
237
+ throw new Error(`Test-change inspection failed: ${detail || `git exited with ${String(result.status)}`}`);
238
+ }
239
+ const removed = new Set();
240
+ for (const record of result.stdout.split("\0")) {
241
+ const [, deletions, filePath] = record.split("\t");
242
+ if (filePath && deletions !== undefined && (deletions === "-" || Number(deletions) > 0)) {
243
+ removed.add(filePath.trim());
244
+ }
245
+ }
246
+ return removed;
247
+ }
248
+ /** Cap on file size the surface analysis reads; larger files use the strict rule. */
249
+ const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
250
+ function isWeakened(input, filePath) {
251
+ const before = readBaseContent(input.repoRoot, input.baseRef, filePath);
252
+ if (before === undefined) {
253
+ return true;
254
+ }
255
+ const after = readWorkingContent(input.repoRoot, filePath);
256
+ if (after === undefined) {
257
+ // Deleted, replaced by a link, unreadable or oversized: cannot prove it still asserts.
258
+ return true;
259
+ }
260
+ if (!hasTestSurface(extractTestSurface(before, filePath))) {
261
+ return true;
262
+ }
263
+ return testSurfaceWeakened(before, after, filePath);
264
+ }
265
+ function readBaseContent(repoRoot, baseRef, filePath) {
266
+ const result = spawnSync("git", ["show", `${baseRef}:${filePath}`], {
267
+ cwd: repoRoot,
268
+ encoding: "utf8",
269
+ maxBuffer: MAX_SURFACE_BYTES * 2
270
+ });
271
+ return result.status === 0 && typeof result.stdout === "string" && result.stdout.length <= MAX_SURFACE_BYTES
272
+ ? result.stdout
273
+ : undefined;
274
+ }
275
+ function readWorkingContent(repoRoot, filePath) {
276
+ const absolutePath = join(repoRoot, filePath);
277
+ try {
278
+ const stats = lstatSync(absolutePath);
279
+ if (!stats.isFile() || stats.size > MAX_SURFACE_BYTES) {
280
+ return undefined;
281
+ }
282
+ return readFileSync(absolutePath, "utf8");
283
+ }
284
+ catch {
285
+ return undefined;
286
+ }
287
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Test surface: what a test file asserts, how many cases it declares and how many it skips.
3
+ *
4
+ * Weakening is judged on this surface rather than on raw removed lines, so ordinary edits
5
+ * (growing an import list, reformatting, appending cases) are allowed while a removed or
6
+ * altered assertion, a dropped case or a newly skipped case is not.
7
+ *
8
+ * The extractor is language-agnostic and heuristic. It is comment- and string-aware so a
9
+ * commented-out assertion does not count as present. Files with no recognizable surface
10
+ * (data, golden and snapshot files, unknown frameworks) are reported as empty so callers
11
+ * can fall back to a stricter rule.
12
+ */
13
+ export interface TestSurface {
14
+ /** Whitespace-normalized assertion expressions, in source order. */
15
+ assertions: string[];
16
+ /** Declared test cases. */
17
+ cases: number;
18
+ /** Skip, todo or disable markers. */
19
+ skips: number;
20
+ }
21
+ export declare function hasTestSurface(surface: TestSurface): boolean;
22
+ export declare function extractTestSurface(content: string, path?: string): TestSurface;
23
+ /**
24
+ * True when `after` removes or alters an assertion, declares fewer cases, or skips more
25
+ * cases than `before`. Added assertions and cases never count as weakening.
26
+ */
27
+ export declare function testSurfaceWeakened(before: string, after: string, path?: string): boolean;