fix(trekexecute): park a re-planned step's earlier new files before the fresh build

The revert of a step sent back to the plan leaves its new files on disk, and --resume built the revised step on top of them: a stale tests/batch.sh holding the rejected line would have passed Verify, the manifest and the success criterion. lib/util/park-attempt-files.mjs moves every still-untracked file the attempt created (from step-N-upward.diff) into the report dir, never deleting it; /trekexecute runs it before the fresh build helper (voyage-lekerepo trial 5, finding 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-18 19:54:54 +02:00
commit 3238327483
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 267 additions and 4 deletions

View file

@ -0,0 +1,114 @@
#!/usr/bin/env node
// lib/util/park-attempt-files.mjs
// A step sent back to the plan (`fix_rounds` 4) is reverted with its new files
// left on disk, untracked: nothing the step made is deleted. On --resume the
// re-planned step is built fresh, but the stale files were still there, and in
// voyage-lekerepo trial 5 (finding 4) an old tests/batch.sh holding the
// rejected line would have passed Verify, the manifest and the success
// criterion. Only the plan critic caught it.
//
// Before the fresh build the executor parks them: every file the attempt
// created (a `new file mode` entry in step-{N}-upward.diff) that is still
// untracked on disk moves into the report dir under its own path. Moved,
// never deleted. A file tracked since then, already gone, or outside the
// repository is kept where it is and named.
//
// Usage:
// node lib/util/park-attempt-files.mjs --diff <step-N-upward.diff> --to <dir> [--cwd <repo>]
// Prints {"parked": [paths], "kept": [{"path", "reason"}]} on stdout.
// Exit: 0 = done (also when nothing was parked), 2 = could not run (bad usage,
// unreadable diff, not a repository). 2 never means the tree is clean.
import { readFileSync, existsSync, mkdirSync, renameSync, lstatSync } from 'node:fs';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { spawnSync } from 'node:child_process';
import { isMain } from './is-main.mjs';
/** The paths a unified git diff creates (`new file mode` after its `diff --git` line). */
export function newFilesInDiff(diffText) {
const paths = [];
let current = null;
for (const line of String(diffText).split('\n')) {
const head = line.match(/^diff --git a\/(\S+) b\/(\S+)$/);
if (head) {
current = head[2];
continue;
}
if (line.startsWith('diff --git ')) {
current = null; // a quoted or spaced path: not read, so never moved
continue;
}
if (current !== null && /^new file mode \d+$/.test(line)) {
paths.push(current);
current = null;
}
}
return paths;
}
function tracked(cwd, path) {
return spawnSync('git', ['ls-files', '--error-unmatch', '--', path], { cwd, encoding: 'utf8' }).status === 0;
}
/**
* @param {{ diffText: string, to: string, cwd?: string }} args
* @returns {{ parked: string[], kept: Array<{path: string, reason: string}> }}
*/
export function parkAttemptFiles({ diffText, to, cwd = process.cwd() }) {
const root = resolve(cwd);
const dest = resolve(root, to);
const parked = [];
const kept = [];
for (const path of newFilesInDiff(diffText)) {
const abs = resolve(root, path);
const rel = relative(root, abs);
if (isAbsolute(path) || rel === '' || rel.startsWith('..') || isAbsolute(rel) || rel.split(/[\\/]/)[0] === '.git') {
kept.push({ path, reason: 'outside the repository: refused' });
continue;
}
if (!existsSync(abs) || !lstatSync(abs).isFile()) {
kept.push({ path, reason: 'not on disk as a file: nothing to park' });
continue;
}
if (tracked(root, rel)) {
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move' });
continue;
}
const target = join(dest, rel);
try {
mkdirSync(dirname(target), { recursive: true });
renameSync(abs, target);
parked.push(rel);
} catch (e) {
kept.push({ path, reason: `could not move: ${e.message}` });
}
}
return { parked, kept };
}
if (isMain(import.meta.url)) {
const argv = process.argv.slice(2);
const opt = (name) => {
const i = argv.indexOf(name);
return i === -1 ? undefined : argv[i + 1];
};
const diff = opt('--diff');
const to = opt('--to');
const cwd = opt('--cwd') ?? process.cwd();
if (!diff || !to) {
process.stderr.write('Usage: park-attempt-files.mjs --diff <step-N-upward.diff> --to <dir> [--cwd <repo>]\n');
process.exit(2);
}
let diffText;
try {
diffText = readFileSync(diff, 'utf8');
} catch (e) {
process.stderr.write(`park-attempt-files: cannot read ${diff}: ${e.message}\n`);
process.exit(2);
}
if (spawnSync('git', ['rev-parse', '--git-dir'], { cwd, encoding: 'utf8' }).status !== 0) {
process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`);
process.exit(2);
}
process.stdout.write(JSON.stringify(parkAttemptFiles({ diffText, to, cwd }), null, 2) + '\n');
}