@skill-harness/core 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-preflight.d.ts +9 -1
- package/dist/authoring-preflight.js +57 -4
- package/dist/authoring.js +11 -2
- package/dist/calibration.d.ts +57 -0
- package/dist/calibration.js +200 -60
- package/dist/canary.d.ts +28 -26
- package/dist/canary.js +109 -92
- package/dist/diagnose.d.ts +2 -0
- package/dist/diagnose.js +12 -3
- package/dist/grade.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/journal.d.ts +3 -0
- package/dist/lint.js +2 -2
- package/dist/regate.js +1 -0
- package/dist/regrade.d.ts +3 -1
- package/dist/regrade.js +90 -73
- package/dist/rescore.js +1 -0
- package/dist/resource-monitor.d.ts +3 -0
- package/dist/resource-monitor.js +72 -0
- package/dist/results.d.ts +9 -4
- package/dist/results.js +4 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +128 -108
- package/dist/saved-run.d.ts +15 -0
- package/dist/saved-run.js +114 -0
- package/dist/scheduler.d.ts +3 -5
- package/dist/scheduler.js +18 -6
- package/dist/sources.js +13 -34
- package/dist/util/exec.d.ts +5 -0
- package/dist/util/exec.js +16 -32
- package/dist/util/fixture-inventory.d.ts +19 -0
- package/dist/util/fixture-inventory.js +68 -0
- package/dist/util/process.d.ts +78 -0
- package/dist/util/process.js +394 -0
- package/dist/workspace.js +13 -29
- package/package.json +1 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { open, opendir } from "node:fs/promises";
|
|
4
|
+
const scopeStorage = new AsyncLocalStorage();
|
|
5
|
+
/** Scope all subprocesses in an evaluation, including concurrent subject/judge calls. */
|
|
6
|
+
export function withProcessScope(scope, work) {
|
|
7
|
+
return scopeStorage.run({ ...scopeStorage.getStore(), ...scope }, work);
|
|
8
|
+
}
|
|
9
|
+
export class ProcessControlError extends Error {
|
|
10
|
+
reason;
|
|
11
|
+
constructor(reason, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.reason = reason;
|
|
14
|
+
this.name = "ProcessControlError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class ProcessOutputLimitError extends ProcessControlError {
|
|
18
|
+
constructor(message) { super("output-limit", message); this.name = "ProcessOutputLimitError"; }
|
|
19
|
+
}
|
|
20
|
+
export function isProcessControlError(error) { return error instanceof ProcessControlError; }
|
|
21
|
+
export function getProcessSignal() { return scopeStorage.getStore()?.signal; }
|
|
22
|
+
export function throwIfProcessAborted() {
|
|
23
|
+
if (getProcessSignal()?.aborted)
|
|
24
|
+
throw new ProcessControlError("aborted", "Process execution aborted before spawn");
|
|
25
|
+
}
|
|
26
|
+
/** Retains at most `limit` bytes, including a single oversized chunk. */
|
|
27
|
+
export class BoundedCapture {
|
|
28
|
+
limit;
|
|
29
|
+
chunks = [];
|
|
30
|
+
tailUsed = 0;
|
|
31
|
+
bytes = 0;
|
|
32
|
+
truncated = false;
|
|
33
|
+
constructor(limit) {
|
|
34
|
+
this.limit = limit;
|
|
35
|
+
if (!Number.isSafeInteger(limit) || limit < 0)
|
|
36
|
+
throw new Error("Invalid output byte limit");
|
|
37
|
+
}
|
|
38
|
+
push(chunk) {
|
|
39
|
+
const remaining = this.limit - this.bytes;
|
|
40
|
+
if (chunk.length > remaining)
|
|
41
|
+
this.truncated = true;
|
|
42
|
+
if (remaining <= 0)
|
|
43
|
+
return;
|
|
44
|
+
const count = Math.min(chunk.length, remaining);
|
|
45
|
+
let offset = 0;
|
|
46
|
+
while (offset < count) {
|
|
47
|
+
let tail = this.chunks.at(-1);
|
|
48
|
+
if (!tail || this.tailUsed === tail.length) {
|
|
49
|
+
tail = Buffer.allocUnsafe(Math.min(64 * 1024, this.limit - this.bytes));
|
|
50
|
+
this.chunks.push(tail);
|
|
51
|
+
this.tailUsed = 0;
|
|
52
|
+
}
|
|
53
|
+
const copied = Math.min(count - offset, tail.length - this.tailUsed);
|
|
54
|
+
chunk.copy(tail, this.tailUsed, offset, offset + copied);
|
|
55
|
+
offset += copied;
|
|
56
|
+
this.tailUsed += copied;
|
|
57
|
+
this.bytes += copied;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
text() {
|
|
61
|
+
const used = this.chunks.map((chunk, index) => index === this.chunks.length - 1 ? chunk.subarray(0, this.tailUsed) : chunk);
|
|
62
|
+
return Buffer.concat(used, this.bytes).toString("utf8");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const MAX_PROCESSES = 128;
|
|
66
|
+
const MAX_THREADS = 16;
|
|
67
|
+
const MAX_PROC_BYTES = 64 * 1024;
|
|
68
|
+
async function procText(path) {
|
|
69
|
+
const file = await open(path, "r");
|
|
70
|
+
try {
|
|
71
|
+
const buffer = Buffer.alloc(MAX_PROC_BYTES + 1);
|
|
72
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
73
|
+
if (bytesRead > MAX_PROC_BYTES)
|
|
74
|
+
throw new Error("proc record too large");
|
|
75
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await file.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Parse only kernel numeric metadata. No command line, environment, file names or output. */
|
|
82
|
+
async function samplePid(pid, gap) {
|
|
83
|
+
try {
|
|
84
|
+
const dir = `/proc/${pid}`;
|
|
85
|
+
const stat = await procText(`${dir}/stat`);
|
|
86
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
87
|
+
if (fields.length < 22)
|
|
88
|
+
return null;
|
|
89
|
+
const value = { pid, start: fields[19], children: [], rss: null,
|
|
90
|
+
user: Number(fields[11]), system: Number(fields[12]), read: null, write: null };
|
|
91
|
+
try {
|
|
92
|
+
const status = await procText(`${dir}/status`);
|
|
93
|
+
const rss = /^VmRSS:\s+(\d+)\s+kB$/m.exec(status);
|
|
94
|
+
if (rss)
|
|
95
|
+
value.rss = Number(rss[1]) * 1024;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
gap("rss-sample-unavailable");
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const io = await procText(`${dir}/io`);
|
|
102
|
+
const r = /^read_bytes:\s+(\d+)$/m.exec(io), w = /^write_bytes:\s+(\d+)$/m.exec(io);
|
|
103
|
+
value.read = r ? Number(r[1]) : null;
|
|
104
|
+
value.write = w ? Number(w[1]) : null;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
gap("io-sample-unavailable");
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const threads = await opendir(`${dir}/task`);
|
|
111
|
+
let count = 0;
|
|
112
|
+
for await (const entry of threads) {
|
|
113
|
+
if (count++ >= MAX_THREADS) {
|
|
114
|
+
gap("thread-sample-limit");
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const children = await procText(`${dir}/task/${entry.name}/children`);
|
|
119
|
+
for (const child of children.trim().split(/\s+/).map(Number).filter(n => n > 0)) {
|
|
120
|
+
if (value.children.length >= MAX_PROCESSES) {
|
|
121
|
+
gap("process-sample-limit");
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
value.children.push(child);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
gap("descendant-sample-race");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
gap("descendant-sample-unavailable");
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function groupExists(pid) {
|
|
142
|
+
try {
|
|
143
|
+
process.kill(-pid, 0);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return error.code === "ESRCH" ? false : null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Shared owned-process lifecycle. Callers retain only their bounded stream representation. */
|
|
151
|
+
export function runProcess(command, args, options = {}) {
|
|
152
|
+
const scope = scopeStorage.getStore() ?? {};
|
|
153
|
+
const signal = options.signal ?? scope.signal;
|
|
154
|
+
const linux = process.platform === "linux";
|
|
155
|
+
const grace = options.killGraceMs ?? 250;
|
|
156
|
+
const outputLimit = options.maxOutputBytes ?? 256 * 1024 * 1024;
|
|
157
|
+
if (!Number.isSafeInteger(outputLimit) || outputLimit < 1)
|
|
158
|
+
return Promise.reject(new Error("Invalid process output limit"));
|
|
159
|
+
for (const value of [scope.maxRssBytes, scope.maxWriteBytes])
|
|
160
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1))
|
|
161
|
+
return Promise.reject(new Error("Invalid sampled resource limit"));
|
|
162
|
+
if (!linux && (scope.maxRssBytes !== undefined || scope.maxWriteBytes !== undefined))
|
|
163
|
+
return Promise.reject(new Error("Sampled resource limits require Linux /proc; unsupported on this platform"));
|
|
164
|
+
if (!Number.isSafeInteger(grace) || grace < 0 || grace > 30_000)
|
|
165
|
+
return Promise.reject(new Error("Invalid process cleanup grace"));
|
|
166
|
+
if (signal?.aborted)
|
|
167
|
+
return Promise.reject(new ProcessControlError("aborted", "Process execution aborted before spawn"));
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const started = performance.now();
|
|
170
|
+
const record = {
|
|
171
|
+
schema: 1, platform: process.platform, scope: linux ? "owned-process-group" : "child-process", pid: null,
|
|
172
|
+
reason: "exited", code: null, durationMs: 0, stdoutBytes: 0, stderrBytes: 0,
|
|
173
|
+
samples: 0, processesObserved: 0, peakRssBytes: null, cpuUserTicks: null, cpuSystemTicks: null,
|
|
174
|
+
readBytes: null, writeBytes: null, gaps: linux ? ["sampling-may-miss-short-lived-processes", "sampled-io-counters-may-include-reaped-children", "process-group-is-not-containment"] : ["resource-sampling-unsupported", "descendant-cleanup-unsupported"],
|
|
175
|
+
cleanup: { termSent: false, killSent: false, groupGone: null, pipesClosed: false },
|
|
176
|
+
};
|
|
177
|
+
const gap = (value) => { if (!record.gaps.includes(value))
|
|
178
|
+
record.gaps.push(value); };
|
|
179
|
+
let child;
|
|
180
|
+
try {
|
|
181
|
+
child = spawn(command, args, { cwd: options.cwd, env: options.env ?? process.env,
|
|
182
|
+
stdio: ["ignore", "pipe", "pipe"], detached: linux });
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
reject(error);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
record.pid = child.pid ?? null;
|
|
189
|
+
let done = false, closed = false, terminating = false, rootExited = false;
|
|
190
|
+
let failure = null;
|
|
191
|
+
let timeout, interval;
|
|
192
|
+
let force, finalTimer;
|
|
193
|
+
let sampleInFlight = null;
|
|
194
|
+
let lastPublishedSample = -Infinity;
|
|
195
|
+
const known = new Map();
|
|
196
|
+
const send = (name) => {
|
|
197
|
+
try {
|
|
198
|
+
if (linux && child.pid)
|
|
199
|
+
process.kill(-child.pid, name);
|
|
200
|
+
else if (!rootExited)
|
|
201
|
+
child.kill(name);
|
|
202
|
+
if (name === "SIGTERM")
|
|
203
|
+
record.cleanup.termSent = true;
|
|
204
|
+
else
|
|
205
|
+
record.cleanup.killSent = true;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error.code !== "ESRCH")
|
|
209
|
+
gap("process-signal-failed");
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const publish = () => {
|
|
213
|
+
record.durationMs = Math.round(performance.now() - started);
|
|
214
|
+
record.cleanup.pipesClosed = closed;
|
|
215
|
+
record.cleanup.groupGone = linux && child.pid ? groupExists(child.pid) === false : null;
|
|
216
|
+
if (linux && record.cleanup.groupGone !== true)
|
|
217
|
+
gap("process-group-disappearance-unverified");
|
|
218
|
+
try {
|
|
219
|
+
scope.onObservation?.(record);
|
|
220
|
+
}
|
|
221
|
+
catch { /* Observability cannot strand a child or change its verdict. */ }
|
|
222
|
+
};
|
|
223
|
+
const finish = async () => {
|
|
224
|
+
if (done)
|
|
225
|
+
return;
|
|
226
|
+
done = true;
|
|
227
|
+
clearTimeout(timeout);
|
|
228
|
+
clearInterval(interval);
|
|
229
|
+
clearTimeout(force);
|
|
230
|
+
clearTimeout(finalTimer);
|
|
231
|
+
signal?.removeEventListener("abort", abort);
|
|
232
|
+
await sampleInFlight;
|
|
233
|
+
child.stdout?.destroy();
|
|
234
|
+
child.stderr?.destroy();
|
|
235
|
+
publish();
|
|
236
|
+
resolve({ code: record.code, observation: record, error: failure });
|
|
237
|
+
};
|
|
238
|
+
const stop = (reason, error) => {
|
|
239
|
+
if (done)
|
|
240
|
+
return;
|
|
241
|
+
if (record.reason === "exited")
|
|
242
|
+
record.reason = reason;
|
|
243
|
+
failure ??= error ?? null;
|
|
244
|
+
if (terminating)
|
|
245
|
+
return;
|
|
246
|
+
terminating = true;
|
|
247
|
+
send("SIGTERM");
|
|
248
|
+
// Keep the hard-stop timer even when the direct child closes first: siblings may remain.
|
|
249
|
+
force = setTimeout(() => {
|
|
250
|
+
if (!linux || !child.pid || groupExists(child.pid) !== false)
|
|
251
|
+
send("SIGKILL");
|
|
252
|
+
finalTimer = setTimeout(() => { void finish(); }, 100);
|
|
253
|
+
}, grace);
|
|
254
|
+
};
|
|
255
|
+
const abort = () => stop("aborted", new ProcessControlError("aborted", "Process execution aborted"));
|
|
256
|
+
const publishSample = () => {
|
|
257
|
+
if (done || terminating || performance.now() - lastPublishedSample < 5000)
|
|
258
|
+
return;
|
|
259
|
+
lastPublishedSample = performance.now();
|
|
260
|
+
try {
|
|
261
|
+
scope.onSample?.({ ...record, reason: "running", durationMs: Math.round(performance.now() - started),
|
|
262
|
+
gaps: [...record.gaps], cleanup: { ...record.cleanup } });
|
|
263
|
+
}
|
|
264
|
+
catch { /* Monitoring must not affect process control. */ }
|
|
265
|
+
};
|
|
266
|
+
const takeSample = async () => {
|
|
267
|
+
if (done)
|
|
268
|
+
return;
|
|
269
|
+
if (!linux || !child.pid) {
|
|
270
|
+
publishSample();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const queue = [child.pid], visited = new Set();
|
|
274
|
+
let rss = 0, rssSeen = false;
|
|
275
|
+
while (queue.length && visited.size < MAX_PROCESSES && !done) {
|
|
276
|
+
const pid = queue.shift();
|
|
277
|
+
if (visited.has(pid))
|
|
278
|
+
continue;
|
|
279
|
+
visited.add(pid);
|
|
280
|
+
const sample = await samplePid(pid, gap);
|
|
281
|
+
if (!sample) {
|
|
282
|
+
gap("process-sample-race");
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const key = `${pid}:${sample.start}`;
|
|
286
|
+
if (!known.has(key) && known.size >= MAX_PROCESSES) {
|
|
287
|
+
gap("process-history-limit");
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
known.set(key, sample);
|
|
291
|
+
if (sample.rss !== null) {
|
|
292
|
+
rss += sample.rss;
|
|
293
|
+
rssSeen = true;
|
|
294
|
+
}
|
|
295
|
+
for (const descendant of sample.children)
|
|
296
|
+
if (!visited.has(descendant)) {
|
|
297
|
+
if (queue.length >= MAX_PROCESSES) {
|
|
298
|
+
gap("process-sample-limit");
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
queue.push(descendant);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (queue.length)
|
|
305
|
+
gap("process-sample-limit");
|
|
306
|
+
record.samples++;
|
|
307
|
+
record.processesObserved = known.size;
|
|
308
|
+
if (rssSeen)
|
|
309
|
+
record.peakRssBytes = Math.max(record.peakRssBytes ?? 0, rss);
|
|
310
|
+
if (known.size) {
|
|
311
|
+
record.cpuUserTicks = [...known.values()].reduce((sum, p) => sum + p.user, 0);
|
|
312
|
+
record.cpuSystemTicks = [...known.values()].reduce((sum, p) => sum + p.system, 0);
|
|
313
|
+
const sumIo = (field) => {
|
|
314
|
+
const values = [...known.values()].map(p => p[field]);
|
|
315
|
+
return values.every(v => v !== null) ? values.reduce((sum, value) => sum + value, 0) : null;
|
|
316
|
+
};
|
|
317
|
+
record.readBytes = sumIo("read");
|
|
318
|
+
record.writeBytes = sumIo("write");
|
|
319
|
+
}
|
|
320
|
+
if (scope.maxRssBytes !== undefined && rssSeen && rss > scope.maxRssBytes)
|
|
321
|
+
stop("resource-limit", new ProcessControlError("resource-limit", "Owned process RSS limit exceeded"));
|
|
322
|
+
if (scope.maxWriteBytes !== undefined && record.writeBytes !== null && record.writeBytes > scope.maxWriteBytes)
|
|
323
|
+
stop("resource-limit", new ProcessControlError("resource-limit", "Owned process write-byte limit exceeded"));
|
|
324
|
+
publishSample();
|
|
325
|
+
};
|
|
326
|
+
const sample = () => {
|
|
327
|
+
if (sampleInFlight || done)
|
|
328
|
+
return;
|
|
329
|
+
sampleInFlight = takeSample().catch(() => gap("resource-sample-unavailable")).finally(() => { sampleInFlight = null; });
|
|
330
|
+
};
|
|
331
|
+
const consume = (kind, chunk) => {
|
|
332
|
+
record[kind === "stdout" ? "stdoutBytes" : "stderrBytes"] += chunk.length;
|
|
333
|
+
if (done || terminating)
|
|
334
|
+
return;
|
|
335
|
+
if (record.stdoutBytes + record.stderrBytes > outputLimit) {
|
|
336
|
+
stop("output-limit", new ProcessOutputLimitError("Process stream byte limit exceeded"));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
(kind === "stdout" ? options.onStdout : options.onStderr)?.(chunk);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
stop("output-limit", error instanceof Error ? error : new Error("Process capture failed"));
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
child.stdout.on("data", chunk => consume("stdout", Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
347
|
+
child.stderr.on("data", chunk => consume("stderr", Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
348
|
+
child.stdout.on("end", () => {
|
|
349
|
+
if (done || terminating)
|
|
350
|
+
return;
|
|
351
|
+
try {
|
|
352
|
+
options.onStdoutEnd?.();
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
stop("output-limit", error instanceof Error ? error : new Error("Process capture failed"));
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
child.on("spawn", () => { sample(); interval = setInterval(sample, 1000); interval.unref(); });
|
|
359
|
+
child.on("exit", code => {
|
|
360
|
+
rootExited = true;
|
|
361
|
+
record.code = code;
|
|
362
|
+
if (linux && child.pid && groupExists(child.pid) !== false)
|
|
363
|
+
stop("exited");
|
|
364
|
+
});
|
|
365
|
+
child.on("error", error => {
|
|
366
|
+
record.reason = "spawn-error";
|
|
367
|
+
failure = error;
|
|
368
|
+
clearTimeout(timeout);
|
|
369
|
+
clearInterval(interval);
|
|
370
|
+
if (!child.pid) {
|
|
371
|
+
done = true;
|
|
372
|
+
signal?.removeEventListener("abort", abort);
|
|
373
|
+
child.stdout?.destroy();
|
|
374
|
+
child.stderr?.destroy();
|
|
375
|
+
publish();
|
|
376
|
+
reject(error);
|
|
377
|
+
}
|
|
378
|
+
else
|
|
379
|
+
stop("spawn-error", error);
|
|
380
|
+
});
|
|
381
|
+
child.on("close", code => {
|
|
382
|
+
closed = true;
|
|
383
|
+
record.code = code;
|
|
384
|
+
if (!terminating)
|
|
385
|
+
void finish();
|
|
386
|
+
});
|
|
387
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
388
|
+
if (signal?.aborted)
|
|
389
|
+
abort();
|
|
390
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0)
|
|
391
|
+
timeout = setTimeout(() => stop("timeout", new Error(`Process timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
//# sourceMappingURL=process.js.map
|
package/dist/workspace.js
CHANGED
|
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { fixtureInventory, hashFixtureFile } from "./util/fixture-inventory.js";
|
|
6
7
|
const GIT_TIMEOUT_MS = 30_000;
|
|
7
8
|
/**
|
|
8
9
|
* Fixture subdirectories copied over the workspace AFTER the baseline commit, so their
|
|
@@ -182,6 +183,7 @@ export function createWorkspace(kind, opts) {
|
|
|
182
183
|
const src = isAbsolute(kind.fixture) ? kind.fixture : resolve(opts.specDir, kind.fixture);
|
|
183
184
|
if (!existsSync(src))
|
|
184
185
|
throw new Error(`fixture not found: ${src}`);
|
|
186
|
+
fixtureInventory(src); // Refuse broad fixtures before copying or starting Git.
|
|
185
187
|
assertKnownMarkers(src);
|
|
186
188
|
const pending = [STAGED_DIR, UNCOMMITTED_DIR].map((d) => join(src, d));
|
|
187
189
|
cpSync(src, cwd, {
|
|
@@ -226,36 +228,18 @@ export function snapshotPaths(cwd, kind) {
|
|
|
226
228
|
if (kind === "none" || !cwd || !existsSync(cwd))
|
|
227
229
|
return null;
|
|
228
230
|
const out = new Map();
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return; // an unreadable subtree is not evidence about the model
|
|
236
|
-
}
|
|
237
|
-
for (const e of entries) {
|
|
238
|
-
if (SNAPSHOT_SKIP.has(e.name))
|
|
239
|
-
continue;
|
|
240
|
-
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
241
|
-
if (SNAPSHOT_SKIP_RELS.has(rel))
|
|
242
|
-
continue;
|
|
243
|
-
const abs = join(dir, e.name);
|
|
244
|
-
if (e.isDirectory()) {
|
|
245
|
-
walk(abs, rel);
|
|
246
|
-
}
|
|
247
|
-
else if (e.isFile()) {
|
|
248
|
-
try {
|
|
249
|
-
out.set(rel, createHash("sha256").update(readFileSync(abs)).digest("hex"));
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
out.set(rel, "<unreadable>");
|
|
253
|
-
}
|
|
254
|
-
}
|
|
231
|
+
try {
|
|
232
|
+
const files = fixtureInventory(cwd, (name, relative) => SNAPSHOT_SKIP.has(name) || SNAPSHOT_SKIP_RELS.has(relative));
|
|
233
|
+
for (const file of files) {
|
|
234
|
+
const hash = createHash("sha256");
|
|
235
|
+
hashFixtureFile(hash, file);
|
|
236
|
+
out.set(file.relative, hash.digest("hex"));
|
|
255
237
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
} // A partial walk cannot establish unchanged paths.
|
|
259
243
|
}
|
|
260
244
|
/**
|
|
261
245
|
* Paths whose content changed between two snapshots — added, removed, modified.
|