@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.
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/tools/preflight.js +8 -4
- package/dist/tools/run-loop.js +7 -1
- package/dist/vendor/adapters/claude-cli.d.ts +4 -0
- package/dist/vendor/adapters/claude-cli.js +81 -13
- package/dist/vendor/contracts/index.d.ts +79 -0
- package/dist/vendor/contracts/index.js +34 -0
- package/dist/vendor/contracts/swarm.js +1 -1
- package/dist/vendor/core/index.d.ts +43 -4
- package/dist/vendor/core/index.js +529 -74
- package/dist/vendor/core/leash.d.ts +11 -1
- package/dist/vendor/core/leash.js +77 -3
- package/dist/vendor/core/output-directories.d.ts +7 -0
- package/dist/vendor/core/output-directories.js +36 -0
- package/dist/vendor/core/policy.d.ts +18 -1
- package/dist/vendor/core/policy.js +113 -10
- package/dist/vendor/core/rollback.d.ts +24 -0
- package/dist/vendor/core/rollback.js +402 -19
- package/dist/vendor/core/test-integrity.d.ts +18 -0
- package/dist/vendor/core/test-integrity.js +114 -1
- package/dist/vendor/core/test-surface.d.ts +27 -0
- package/dist/vendor/core/test-surface.js +231 -0
- package/dist/vendor/core/verified-handoff.d.ts +1 -0
- package/dist/vendor/core/verified-handoff.js +6 -0
- package/dist/vendor/core/verifier-input.d.ts +4 -0
- package/dist/vendor/core/verifier-input.js +107 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
105
|
-
|
|
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",
|
|
138
|
-
|
|
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
|
-
|
|
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[];
|