@martinloop/mcp 0.8.5 → 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.
- package/dist/arcade/capabilities.d.ts +1 -1
- package/dist/arcade/capabilities.js +2 -1
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/server-validation.js +9 -4
- package/dist/server.js +45 -3
- package/dist/tools/pr-tools.js +12 -0
- package/dist/tools/preflight.js +8 -4
- package/dist/tools/run-controls.d.ts +5 -0
- package/dist/tools/run-controls.js +6 -1
- package/dist/vendor/adapters/claude-cli.js +79 -6
- package/dist/vendor/contracts/index.d.ts +21 -0
- package/dist/vendor/contracts/swarm.js +1 -1
- package/dist/vendor/core/grounding.js +31 -1
- package/dist/vendor/core/index.d.ts +5 -4
- package/dist/vendor/core/index.js +80 -19
- 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/rollback.d.ts +24 -0
- package/dist/vendor/core/rollback.js +372 -19
- package/dist/vendor/core/test-integrity.d.ts +18 -0
- package/dist/vendor/core/test-integrity.js +91 -1
- package/dist/vendor/core/test-surface.d.ts +27 -0
- package/dist/vendor/core/test-surface.js +228 -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 +2 -0
- package/dist/vendor/core/verifier-input.js +59 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
105
|
-
|
|
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",
|
|
138
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|