@feigi/fleet-ctl 3.22.3 → 3.22.4
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/package.json +1 -1
- package/scripts/recipe-prove.mjs +47 -22
- package/scripts/recipe-prove.test.mjs +81 -3
package/package.json
CHANGED
package/scripts/recipe-prove.mjs
CHANGED
|
@@ -42,14 +42,17 @@
|
|
|
42
42
|
// the reader stays the one authority on what a usable cache is. A failed
|
|
43
43
|
// proof never touches an existing cache.
|
|
44
44
|
//
|
|
45
|
-
// Exit 0: proven, cache written (its path and contents on stdout)
|
|
46
|
-
//
|
|
45
|
+
// Exit 0: proven, cache written (its path and contents on stdout); the log
|
|
46
|
+
// directory under $TMPDIR is removed.
|
|
47
|
+
// Exit 1: NOT PROVEN — no cache written; the reason on stderr. The log
|
|
48
|
+
// directory under $TMPDIR is kept when the reason names a log in it,
|
|
49
|
+
// and removed when it names none (the Recipe cache reader's refusal).
|
|
47
50
|
// Exit 2: no verdict on the proof — it could not be attempted (usage, not a
|
|
48
51
|
// repository, origin/main missing, the worktree could not be made), or
|
|
49
52
|
// a git or filesystem fault stopped it before the cache was settled
|
|
50
53
|
// (a git command could not be started, the reader's own git
|
|
51
54
|
// included, or the reader's `sh`; the cache or its temp file could
|
|
52
|
-
// not be written).
|
|
55
|
+
// not be written). The log directory under $TMPDIR is removed.
|
|
53
56
|
|
|
54
57
|
import { spawnSync } from "node:child_process";
|
|
55
58
|
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -148,9 +151,9 @@ const didNotRun = (rc) => rc === 126 || rc === 127;
|
|
|
148
151
|
// install creates dirties the tree as surely as a rewrite, and the untracked
|
|
149
152
|
// mode is config — under status.showUntrackedFiles=no it is invisible unless
|
|
150
153
|
// pinned.
|
|
151
|
-
function treeChanges(wt) {
|
|
154
|
+
function treeChanges(wt, log) {
|
|
152
155
|
const s = git(["status", "--porcelain", "-uall"], wt);
|
|
153
|
-
if (!s.ok) throw notProven(`could not read the tree state in the throwaway worktree: ${s.err}`);
|
|
156
|
+
if (!s.ok) throw notProven(`could not read the tree state in the throwaway worktree: ${s.err}; install output: ${log}`);
|
|
154
157
|
return s.out;
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -161,8 +164,10 @@ function prove(o, wt, logs) {
|
|
|
161
164
|
throw notProven(`the Install step '${o.install}' did not run (exit ${irc}: not executable or not found); install output: ${installLog}`);
|
|
162
165
|
}
|
|
163
166
|
if (irc !== 0) throw notProven(`the Install step '${o.install}' failed (exit ${irc}); install output: ${installLog}`);
|
|
164
|
-
const dirty = treeChanges(wt);
|
|
165
|
-
if (dirty)
|
|
167
|
+
const dirty = treeChanges(wt, installLog);
|
|
168
|
+
if (dirty) {
|
|
169
|
+
throw notProven(`the Install step changed the tree (first: ${dirty.split("\n")[0]}); it must leave every file as checked out; install output: ${installLog}`);
|
|
170
|
+
}
|
|
166
171
|
|
|
167
172
|
const testLog = join(logs, "test.log");
|
|
168
173
|
const trc = sh(o.test, wt, testLog);
|
|
@@ -179,7 +184,7 @@ function prove(o, wt, logs) {
|
|
|
179
184
|
const n = Number(o.testCount);
|
|
180
185
|
if (n === 0) throw notProven(`vacuous: a test count of 0 is a run that executed no tests; ${where}`);
|
|
181
186
|
if (!new RegExp(`(^|[^0-9])0*${n}([^0-9]|$)`).test(o.countLine)) {
|
|
182
|
-
throw notProven(`the count line '${o.countLine}' does not carry the test count ${n}`);
|
|
187
|
+
throw notProven(`the count line '${o.countLine}' does not carry the test count ${n}; ${where}`);
|
|
183
188
|
}
|
|
184
189
|
if (!readFileSync(testLog, "utf8").includes(o.countLine)) {
|
|
185
190
|
throw notProven(`vacuous: the Test entrypoint's output does not contain the count line '${o.countLine}' — no evidence any test ran; ${where}`);
|
|
@@ -193,15 +198,15 @@ function prove(o, wt, logs) {
|
|
|
193
198
|
// What the unmutated Test run rewrote is not the mutation's work: restore
|
|
194
199
|
// every tracked file first, so the change read below is the mutation's alone.
|
|
195
200
|
const reset = git(["reset", "--hard", "-q", "HEAD"], wt);
|
|
196
|
-
if (!reset.ok) throw notProven(`could not restore the tree before the mutation: ${reset.err}`);
|
|
201
|
+
if (!reset.ok) throw notProven(`could not restore the tree before the mutation: ${reset.err}; ${where}`);
|
|
197
202
|
const mutateLog = join(logs, "mutate.log");
|
|
198
203
|
const mrc = sh(o.mutate, wt, mutateLog);
|
|
199
204
|
if (mrc !== 0) throw notProven(`the mutation command '${o.mutate}' failed (exit ${mrc}); its output: ${mutateLog}`);
|
|
200
205
|
// Against HEAD, not the index: a mutation that stages its edit (`git mv`,
|
|
201
206
|
// `git add`) changed a tracked file just as surely.
|
|
202
207
|
const changed = git(["diff", "HEAD", "--name-only"], wt);
|
|
203
|
-
if (!changed.ok) throw notProven(`could not read what the mutation changed: ${changed.err}`);
|
|
204
|
-
if (!changed.out) throw notProven(`the mutation (${o.mutation}) changed no tracked file — mutate a test, or the code a test covers`);
|
|
208
|
+
if (!changed.ok) throw notProven(`could not read what the mutation changed: ${changed.err}; its output: ${mutateLog}`);
|
|
209
|
+
if (!changed.out) throw notProven(`the mutation (${o.mutation}) changed no tracked file — mutate a test, or the code a test covers; its output: ${mutateLog}`);
|
|
205
210
|
const mutatedLog = join(logs, "test-mutated.log");
|
|
206
211
|
const red = sh(o.test, wt, mutatedLog);
|
|
207
212
|
if (didNotRun(red)) throw notProven(`the Test entrypoint did not run on the mutated tree (exit ${red}); test output: ${mutatedLog}`);
|
|
@@ -275,6 +280,19 @@ function removeWorktree(wt, repo) {
|
|
|
275
280
|
tryGit(["worktree", "prune"]);
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
// The log directory outlives the run only for a refusal that names it, so the
|
|
284
|
+
// caller can read the log it points at. A proof that held has its evidence
|
|
285
|
+
// summarised in the cache, a run with no verdict was stopped by a fault no log
|
|
286
|
+
// records, and a refusal that names no log (the Recipe cache reader's) has
|
|
287
|
+
// nothing in the directory to point at. Best-effort, as removeWorktree is.
|
|
288
|
+
function removeLogs(logs) {
|
|
289
|
+
try {
|
|
290
|
+
rmSync(logs, { recursive: true, force: true });
|
|
291
|
+
} catch (e) {
|
|
292
|
+
process.stderr.write(`${NAME}: could not remove the log directory ${logs}: ${e.message}\n`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
278
296
|
function main(argv) {
|
|
279
297
|
const o = parseArgs(argv);
|
|
280
298
|
if (!git(["rev-parse", "--git-dir"], o.repo).ok) throw cannot(`${o.repo} is not a git repository`);
|
|
@@ -285,19 +303,26 @@ function main(argv) {
|
|
|
285
303
|
if (!sha.ok) throw cannot("origin/main does not resolve to a commit — fetch it; the proof runs against it");
|
|
286
304
|
|
|
287
305
|
const logs = mkdtempSync(join(tmpdir(), `${NAME}-`));
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
if (!add.ok) throw cannot(`could not create the throwaway worktree: ${add.err}`);
|
|
291
|
-
let proof;
|
|
306
|
+
const cache = join(workspace, ".fleet", "recipe.json");
|
|
307
|
+
let recipe;
|
|
292
308
|
try {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
309
|
+
const wt = join(logs, "wt");
|
|
310
|
+
const add = git(["worktree", "add", "--detach", wt, sha.out], o.repo);
|
|
311
|
+
if (!add.ok) throw cannot(`could not create the throwaway worktree: ${add.err}`);
|
|
312
|
+
let proof;
|
|
313
|
+
try {
|
|
314
|
+
proof = prove(o, wt, logs);
|
|
315
|
+
} finally {
|
|
316
|
+
removeWorktree(wt, o.repo);
|
|
317
|
+
}
|
|
297
318
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
319
|
+
recipe = { install: o.install, test: o.test, derivedAt: sha.out, installClean: true, ...proof };
|
|
320
|
+
writeCache(cache, recipe, o.repo);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
if (!(e instanceof Refusal && e.message.includes(logs))) removeLogs(logs);
|
|
323
|
+
throw e;
|
|
324
|
+
}
|
|
325
|
+
removeLogs(logs);
|
|
301
326
|
process.stdout.write(`${NAME}: PROVEN — Recipe cache written to ${cache}\n${JSON.stringify(recipe)}\n`);
|
|
302
327
|
}
|
|
303
328
|
|
|
@@ -378,13 +378,90 @@ function assertNoWorktreeLeft(tmp) {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
|
|
381
|
+
// The log directories the proof made in its TMPDIR.
|
|
382
|
+
const logDirs = (tmp) => readdirSync(tmp).filter((n) => n.startsWith("recipe-prove-"));
|
|
383
|
+
|
|
384
|
+
test("a proof that held leaves neither a worktree nor a log directory behind in its TMPDIR", () => {
|
|
382
385
|
const { dir } = repo(MAVEN_FILES);
|
|
383
386
|
const r = prove(dir, MAVEN_PROOF);
|
|
384
387
|
assert.equal(r.status, 0, r.err);
|
|
388
|
+
assert.deepEqual(logDirs(r.tmp), []);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("a proof that did not hold keeps its log directory, with the log its refusal names", () => {
|
|
392
|
+
const { dir } = repo(MAVEN_FILES);
|
|
393
|
+
const r = prove(dir, ["--install", "echo fetching deps; exit 3", ...MAVEN_PROOF.slice(2)]);
|
|
394
|
+
assert.equal(r.status, 1, r.err);
|
|
395
|
+
const log = r.err.match(/install output: (\S+)/);
|
|
396
|
+
assert.ok(log, r.err);
|
|
397
|
+
const kept = logDirs(r.tmp);
|
|
398
|
+
assert.equal(kept.length, 1, `log directories in ${r.tmp}: ${kept}`);
|
|
399
|
+
assert.equal(log[1], join(r.tmp, kept[0], "install.log"));
|
|
400
|
+
assert.match(readFileSync(log[1], "utf8"), /fetching deps/);
|
|
385
401
|
assertNoWorktreeLeft(r.tmp);
|
|
386
402
|
});
|
|
387
403
|
|
|
404
|
+
// Every refusal the proof stage throws names a log in the directory it keeps,
|
|
405
|
+
// so a kept directory is never one the caller has no path to.
|
|
406
|
+
const KEPT_REFUSALS = [
|
|
407
|
+
{
|
|
408
|
+
name: "an Install step that changed the tree",
|
|
409
|
+
files: MAVEN_FILES,
|
|
410
|
+
args: ["--install", ": > deps.lock", ...MAVEN_PROOF.slice(2)],
|
|
411
|
+
reason: /the Install step changed the tree/,
|
|
412
|
+
log: "install.log",
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
name: "a count line that lacks the claimed count",
|
|
416
|
+
files: MAVEN_FILES,
|
|
417
|
+
args: ["--install", "true", "--test", "mvn -q test", "--count-line", "Tests run: 1,", "--test-count", "7"],
|
|
418
|
+
reason: /does not carry the test count 7/,
|
|
419
|
+
log: "test.log",
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "a mutation that changed no tracked file",
|
|
423
|
+
files: GO_FILES,
|
|
424
|
+
args: ["--install", "true", "--test", "go test ./...", "--mutate", ": > new_test.go", "--mutation", "adds a file"],
|
|
425
|
+
reason: /changed no tracked file/,
|
|
426
|
+
log: "mutate.log",
|
|
427
|
+
},
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
for (const c of KEPT_REFUSALS) {
|
|
431
|
+
test(`${c.name} keeps its log directory, and the refusal names the log in it`, () => {
|
|
432
|
+
const { dir } = repo(c.files);
|
|
433
|
+
const r = prove(dir, c.args);
|
|
434
|
+
assert.equal(r.status, 1, r.err);
|
|
435
|
+
assert.match(r.err, c.reason);
|
|
436
|
+
const kept = logDirs(r.tmp);
|
|
437
|
+
assert.equal(kept.length, 1, `log directories in ${r.tmp}: ${kept}`);
|
|
438
|
+
const log = join(r.tmp, kept[0], c.log);
|
|
439
|
+
assert.ok(r.err.includes(log), `${log} not named in: ${r.err}`);
|
|
440
|
+
assert.ok(existsSync(log), `${log} does not exist`);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
test("a refusal that names no log leaves no log directory behind", () => {
|
|
445
|
+
// The Recipe cache reader refuses a Test entrypoint ending in `;` after the
|
|
446
|
+
// proof held, so no log in the directory is what the refusal is about.
|
|
447
|
+
const { dir } = repo(MAVEN_FILES);
|
|
448
|
+
const r = prove(dir, ["--install", "true", "--test", "mvn -q test;", "--count-line", "Tests run: 1,", "--test-count", "1"]);
|
|
449
|
+
assert.equal(r.status, 1, r.err);
|
|
450
|
+
assert.match(r.err, /the Recipe cache reader refuses what was proven/);
|
|
451
|
+
assert.deepEqual(logDirs(r.tmp), []);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a throwaway worktree that cannot be made is exit 2 and leaves no log directory behind", () => {
|
|
455
|
+
const realGit = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
|
|
456
|
+
const { dir } = repo(MAVEN_FILES);
|
|
457
|
+
const bin = tempDir("recipe-prove-path-");
|
|
458
|
+
writeExecStub(join(bin, "git"), `#!/bin/sh\nif [ "$1" = worktree ] && [ "$2" = add ]; then echo 'fatal: no worktree here' >&2; exit 128; fi\nexec '${realGit}' "$@"\n`);
|
|
459
|
+
const r = prove(dir, MAVEN_PROOF, { env: { PATH: `${bin}:${BIN}:${process.env.PATH}` } });
|
|
460
|
+
assert.equal(r.status, 2, r.err);
|
|
461
|
+
assert.match(r.err, /could not create the throwaway worktree: fatal: no worktree here/);
|
|
462
|
+
assert.deepEqual(logDirs(r.tmp), []);
|
|
463
|
+
});
|
|
464
|
+
|
|
388
465
|
test("a git that cannot be started is named as such, not reported as a missing repository", () => {
|
|
389
466
|
const { dir } = repo(MAVEN_FILES);
|
|
390
467
|
const r = prove(dir, MAVEN_PROOF, { env: { PATH: "/nonexistent" } });
|
|
@@ -408,7 +485,7 @@ test("a git that stops starting during the Install step is no verdict, never NOT
|
|
|
408
485
|
assert.match(r.err, /could not start git: .*ENOENT/);
|
|
409
486
|
assert.match(r.err, /skipped git worktree remove/);
|
|
410
487
|
assert.doesNotMatch(r.err, /NOT PROVEN/);
|
|
411
|
-
|
|
488
|
+
assert.deepEqual(logDirs(r.tmp), [], "no verdict keeps no logs");
|
|
412
489
|
});
|
|
413
490
|
|
|
414
491
|
// A git that started and ran is never "could not start": here its status
|
|
@@ -503,7 +580,7 @@ test("read-only install output cannot turn a proof that held into a crash", { sk
|
|
|
503
580
|
const r = prove(dir, ["--install", READ_ONLY_INSTALL, ...MAVEN_PROOF.slice(2)]);
|
|
504
581
|
assert.equal(r.status, 0, r.err);
|
|
505
582
|
assert.equal(JSON.parse(readFileSync(cachePath(dir), "utf8")).derivedAt, head);
|
|
506
|
-
|
|
583
|
+
assert.deepEqual(logDirs(r.tmp), []);
|
|
507
584
|
assert.equal(git(dir, "worktree", "list", "--porcelain").split("\n").filter((l) => l.startsWith("worktree ")).length, 1);
|
|
508
585
|
});
|
|
509
586
|
|
|
@@ -527,6 +604,7 @@ test("a cache that cannot be written is exit 2 with its reason, never a stack tr
|
|
|
527
604
|
assert.doesNotMatch(r.err, /\n\s+at /);
|
|
528
605
|
assert.doesNotMatch(r.err, /NOT PROVEN/);
|
|
529
606
|
assert.equal(r.out, "");
|
|
607
|
+
assert.deepEqual(logDirs(r.tmp), [], "no verdict keeps no logs");
|
|
530
608
|
} finally {
|
|
531
609
|
chmodSync(join(dir, ".fleet"), 0o755);
|
|
532
610
|
}
|