Update test script in package.json and enhance patch_files.js with chroot support and improved path handling for file operations.

This commit is contained in:
sebseb7
2025-08-11 17:27:46 +02:00
parent 612a4a6b9d
commit b26d0d3f7e
3 changed files with 722 additions and 56 deletions

View File

@@ -49,7 +49,7 @@
}, },
"scripts": { "scripts": {
"start": "node cli.js", "start": "node cli.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "node tests/run-tests.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

626
tests/run-tests.js Normal file
View File

@@ -0,0 +1,626 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs';
import fsSync from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { run as runPatch } from '../tools/patch_files.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const sandboxRoot = path.resolve(repoRoot, 'tmp', 'patch-tests');
async function rimraf(dir) {
await fs.rm(dir, { recursive: true, force: true });
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function writeFiles(baseDir, filesMap) {
for (const [rel, content] of Object.entries(filesMap || {})) {
const filePath = path.resolve(baseDir, rel);
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
}
async function readFileIfExists(filePath) {
try {
return await fs.readFile(filePath, 'utf8');
} catch {
return undefined;
}
}
function buildPatch(lines, useCRLF = false) {
const eol = useCRLF ? '\r\n' : '\n';
return lines.join(eol);
}
function begin() { return '*** Begin Patch'; }
function end() { return '*** End Patch'; }
function addFile(p) { return `*** Add File: ${p}`; }
function updateFile(p) { return `*** Update File: ${p}`; }
function moveTo(p) { return `*** Move to: ${p}`; }
function endOfFile() { return '*** End of File'; }
function k(line) { return ` ${line}`; }
function d(line) { return `-${line}`; }
function a(line) { return `+${line}`; }
function at(atLine = '') { return atLine ? `@@ ${atLine}` : '@@'; }
function slugify(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function expectEqual(actual, expected, label) {
if (actual !== expected) {
const ellipsize = (s) => (s.length > 400 ? s.slice(0, 400) + '…' : s);
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected ?? '<<undefined>>')}\nActual:\n${ellipsize(actual ?? '<<undefined>>')}`);
}
}
async function runCase(index, testCase) {
const idx = String(index + 1).padStart(2, '0');
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
await rimraf(caseDir);
await ensureDir(caseDir);
// Setup initial files
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
const patchText = await testCase.patch({ dir: caseDir });
let threw = false;
let errorMessage = '';
try {
const result = await runPatch({ patch: patchText });
if (testCase.expect?.resultEquals) {
expectEqual(result, testCase.expect.resultEquals, 'Tool result');
}
} catch (err) {
threw = true;
errorMessage = err?.message || String(err);
}
if (testCase.expect?.error) {
if (!threw) {
throw new Error(`Expected error matching ${testCase.expect.error} but call succeeded`);
}
const re = typeof testCase.expect.error === 'string' ? new RegExp(testCase.expect.error.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : testCase.expect.error;
if (!re.test(errorMessage)) {
throw new Error(`Error did not match.\nExpected: ${re}\nActual: ${errorMessage}`);
}
} else if (threw) {
throw new Error(`Unexpected error: ${errorMessage}`);
}
if (testCase.expect?.files) {
for (const [rel, expectedContent] of Object.entries(testCase.expect.files)) {
const filePath = path.resolve(caseDir, rel);
const content = await readFileIfExists(filePath);
if (content === undefined) {
throw new Error(`Expected file missing: ${rel}`);
}
expectEqual(content, expectedContent, `Content of ${rel}`);
}
}
if (testCase.expect?.exists) {
for (const rel of testCase.expect.exists) {
const filePath = path.resolve(caseDir, rel);
if (!fsSync.existsSync(filePath)) {
throw new Error(`Expected path to exist: ${rel}`);
}
}
}
if (testCase.expect?.notExists) {
for (const rel of testCase.expect.notExists) {
const filePath = path.resolve(caseDir, rel);
if (fsSync.existsSync(filePath)) {
throw new Error(`Expected path NOT to exist: ${rel}`);
}
}
}
}
function cases() {
const list = [];
// 1. Add simple file
list.push({
name: 'add simple file',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'file1.txt')),
a('hello'),
a('world'),
end(),
]),
expect: {
files: { 'file1.txt': 'hello\nworld' }
}
});
// 2. Add nested directories
list.push({
name: 'add nested directories',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'a/b/c.txt')),
a('alpha'),
a('beta'),
end(),
]),
expect: {
files: { 'a/b/c.txt': 'alpha\nbeta' }
}
});
// 3. Update simple replacement
list.push({
name: 'update simple replacement',
before: { 'greet.txt': 'line1\nreplace me\nline3' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'greet.txt')),
k('line1'),
d('replace me'),
a('replaced'),
k('line3'),
end(),
]),
expect: {
files: { 'greet.txt': 'line1\nreplaced\nline3' }
}
});
// 4. Update multiple chunks
list.push({
name: 'update multiple chunks',
before: { 'multi.txt': 'l1\nl2\nl3\nl4\nl5\nl6\nl7' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'multi.txt')),
k('l1'),
k('l2'),
d('l3'),
a('L3'),
k('l4'),
k('l5'),
d('l6'),
a('L6'),
k('l7'),
end(),
]),
expect: {
files: { 'multi.txt': 'l1\nl2\nL3\nl4\nl5\nL6\nl7' }
}
});
// 5. Insert at beginning
list.push({
name: 'insert at beginning',
before: { 'begin.txt': 'B1\nB2' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'begin.txt')),
a('A0'),
k('B1'),
k('B2'),
end(),
]),
expect: { files: { 'begin.txt': 'A0\nB1\nB2' } }
});
// 6. Insert at end with EOF marker
list.push({
name: 'insert at end with EOF',
before: { 'end.txt': 'E1\nE2' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'end.txt')),
k('E1'),
k('E2'),
a('E3'),
endOfFile(),
end(),
]),
expect: { files: { 'end.txt': 'E1\nE2\nE3' } }
});
// 7. Delete a line
list.push({
name: 'delete a line',
before: { 'delete.txt': 'X\nY\nZ' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'delete.txt')),
k('X'),
d('Y'),
k('Z'),
end(),
]),
expect: { files: { 'delete.txt': 'X\nZ' } }
});
// 8. Whitespace rstrip match
list.push({
name: 'rstrip whitespace match',
before: { 'ws.txt': 'foo \nbar' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'ws.txt')),
k('foo'), // matches 'foo ' via rstrip
k('bar'),
end(),
]),
expect: { files: { 'ws.txt': 'foo \nbar' } }
});
// 9. Trim match
list.push({
name: 'trim whitespace match',
before: { 'trim.txt': ' alpha \n beta' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'trim.txt')),
k('alpha'), // matches ' alpha ' via trim
k(' beta'),
end(),
]),
expect: { files: { 'trim.txt': ' alpha \n beta' } }
});
// 10. Use def_str to anchor (do not duplicate the anchor line in context)
list.push({
name: 'def_str anchor',
before: { 'code.js': 'function a() {}\nfunction greet() {\n return 1;\n}\nfunction z() {}' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'code.js')),
at('function greet() {'),
// context starts at the next line after the def_str match
d(' return 1;'),
a(' return 2;'),
k('}'),
end(),
]),
expect: { files: { 'code.js': 'function a() {}\nfunction greet() {\n return 2;\n}\nfunction z() {}' } }
});
// 11. Bare @@ marker allowed
list.push({
name: 'bare @@ marker',
before: { 'marker.txt': 'L1\nL2\nL3' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'marker.txt')),
at(),
k('L1'),
d('L2'),
a('X2'),
k('L3'),
end(),
]),
expect: { files: { 'marker.txt': 'L1\nX2\nL3' } }
});
// 12. Move/rename file with content change
list.push({
name: 'move and change',
before: { 'mv.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'mv.txt')),
moveTo(path.resolve(dir, 'moved/mv.txt')),
k('A'),
d('B'),
a('C'),
end(),
]),
expect: { files: { 'moved/mv.txt': 'A\nC' }, notExists: ['mv.txt'] }
});
// 13. Delete file
list.push({
name: 'delete file',
before: { 'del/me.txt': 'bye' },
patch: async ({ dir }) => buildPatch([
begin(),
`*** Delete File: ${path.resolve(dir, 'del/me.txt')}`,
end(),
]),
expect: { notExists: ['del/me.txt'] }
});
// 14. Combined add/update/delete
list.push({
name: 'combined operations',
before: { 'combo/u.txt': 'X\nY', 'combo/d.txt': 'gone' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'combo/u.txt')),
k('X'),
a('Z'),
k('Y'),
`*** Delete File: ${path.resolve(dir, 'combo/d.txt')}`,
addFile(path.resolve(dir, 'combo/a.txt')),
a('new'),
end(),
]),
expect: { files: { 'combo/u.txt': 'X\nZ\nY', 'combo/a.txt': 'new' }, notExists: ['combo/d.txt'] }
});
// 15. Add with CRLF patch
list.push({
name: 'add with CRLF patch',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'crlf/add.txt')),
a('one'),
a('two'),
end(),
], true),
expect: { files: { 'crlf/add.txt': 'one\ntwo' } }
});
// 16. Update with CRLF patch
list.push({
name: 'update with CRLF patch',
before: { 'crlf/up.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'crlf/up.txt')),
k('A'),
d('B'),
a('C'),
end(),
], true),
expect: { files: { 'crlf/up.txt': 'A\nC' } }
});
// 17. Ambiguous content resolved by def_str
list.push({
name: 'ambiguous resolved by def_str',
before: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 2;\n}' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'amb.js')),
at('function target() {'),
k('function target() {'),
d(' let x = 2;'),
a(' let x = 42;'),
k('}'),
end(),
]),
expect: { files: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 42;\n}' } }
});
// 18. Update missing file -> error
list.push({
name: 'update missing file error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'missing.txt')),
k('x'),
end(),
]),
expect: { error: /Patch error: Update File Error - missing file:/ }
});
// 19. Delete missing file -> error
list.push({
name: 'delete missing file error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
`*** Delete File: ${path.resolve(dir, 'nope.txt')}`,
end(),
]),
expect: { error: /Patch error: Delete File Error - missing file:/ }
});
// 20. Add existing file -> error
list.push({
name: 'add existing file error',
before: { 'exists.txt': 'already' },
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'exists.txt')),
a('new'),
end(),
]),
expect: { error: /Patch error: Add File Error - file already exists:/ }
});
// 21. Duplicate update -> error
list.push({
name: 'duplicate update error',
before: { 'dup.txt': 'X' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'dup.txt')),
k('X'),
updateFile(path.resolve(dir, 'dup.txt')),
k('X'),
end(),
]),
expect: { error: /Patch error: Duplicate update for file:/ }
});
// 22. Invalid line in update section -> error
list.push({
name: 'invalid line in section error',
before: { 'bad.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'bad.txt')),
k('A'),
'?weird',
k('B'),
end(),
]),
expect: { error: /Patch error: Invalid Line:/ }
});
// 23. Missing end sentinel -> error
list.push({
name: 'missing end sentinel error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'x.txt')),
a('x'),
// Intentionally no end sentinel here; we'll strip it below
]).replace(/\*\*\* End Patch$/, ''),
expect: { error: /Patch error: Invalid patch text - missing sentinels/ }
});
// 24. Unknown line while parsing -> error
list.push({
name: 'unknown line while parsing error',
before: {},
patch: async () => buildPatch([
begin(),
'some random line',
end(),
]),
expect: { error: /Patch error: Unknown line while parsing:/ }
});
// 25. Add empty file (no + lines)
list.push({
name: 'add empty file',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'empty.txt')),
end(),
]),
expect: { files: { 'empty.txt': '' } }
});
// 26. Replace whole file contents
list.push({
name: 'replace whole file',
before: { 'whole.txt': 'a\nb\nc' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'whole.txt')),
d('a'),
d('b'),
d('c'),
a('x'),
a('y'),
end(),
]),
expect: { files: { 'whole.txt': 'x\ny' } }
});
// 27. Multiple file updates
list.push({
name: 'multi-file updates',
before: { 'm1.txt': '1', 'm2.txt': 'A' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'm1.txt')),
d('1'),
a('2'),
updateFile(path.resolve(dir, 'm2.txt')),
d('A'),
a('B'),
end(),
]),
expect: { files: { 'm1.txt': '2', 'm2.txt': 'B' } }
});
// 28. Rename only (no content changes)
list.push({
name: 'rename only',
before: { 'r/from.txt': 'same' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'r/from.txt')),
moveTo(path.resolve(dir, 'r/to.txt')),
end(),
]),
expect: { files: { 'r/to.txt': 'same' }, notExists: ['r/from.txt'] }
});
// 29. EOF context matching at end
list.push({
name: 'EOF context matching',
before: { 'eof.txt': 'first\nsecond\nthird' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'eof.txt')),
k('second'),
a('inserted'),
k('third'),
endOfFile(),
end(),
]),
expect: { files: { 'eof.txt': 'first\nsecond\ninserted\nthird' } }
});
// 30. Add multiple files in single patch
list.push({
name: 'add multiple files',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'multi/a.txt')),
a('A'),
addFile(path.resolve(dir, 'multi/b.txt')),
a('B'),
addFile(path.resolve(dir, 'multi/c.txt')),
a('C'),
end(),
]),
expect: { files: { 'multi/a.txt': 'A', 'multi/b.txt': 'B', 'multi/c.txt': 'C' } }
});
return list;
}
async function main() {
const all = cases();
await ensureDir(sandboxRoot);
const results = [];
let passed = 0;
let failed = 0;
for (let i = 0; i < all.length; i++) {
const tc = all[i];
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
try {
await runCase(i, tc);
console.log(`${label}`);
results.push({ name: tc.name, ok: true });
passed++;
} catch (err) {
console.error(`${label}`);
console.error(String(err?.stack || err));
results.push({ name: tc.name, ok: false, error: String(err?.message || err) });
failed++;
}
}
console.log('');
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
if (failed > 0) process.exit(1);
}
main().catch((err) => {
console.error('Fatal error in test runner:', err);
process.exit(1);
});

View File

@@ -1,19 +1,14 @@
#!/usr/bin/env node #!/usr/bin/env node
/** const desc = `
* A self-contained JavaScript utility for applying human-readable This is a custom utility that makes it more convenient to add, remove, move, or edit code files. 'apply_patch' effectively allows you to execute a diff/patch against a file,
* "pseudo-diff" patch files to a collection of text files. but the format of the diff specification is unique to this task, so pay careful attention to these instructions.
* To use the 'apply_patch' command, you should pass a message of the following structure as "input":
*** Begin Patch *** Begin Patch
[YOUR_PATH] [YOUR_PATH]
*** End Patch *** End Patch
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format. Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
*** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete. *** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete.
@@ -24,7 +19,7 @@ For each snippet of code that needs to be changed, repeat the following:
[context_after] -> See below for further instructions on context. [context_after] -> See below for further instructions on context.
For instructions on [context_before] and [context_after]: For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines. - By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have: - If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass @@ class BaseClass
[3 lines of pre-context] [3 lines of pre-context]
@@ -32,7 +27,7 @@ For instructions on [context_before] and [context_after]:
+ [new_code] + [new_code]
[3 lines of post-context] [3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance: - If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple '@@' statements to jump to the right context. For instance:
@@ class BaseClass @@ class BaseClass
@@ def method(): @@ def method():
@@ -42,12 +37,7 @@ For instructions on [context_before] and [context_after]:
[3 lines of post-context] [3 lines of post-context]
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below. Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
`;
*
*
*
*/
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Domain objects // Domain objects
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -109,16 +99,55 @@ class Patch {
} }
} }
// --------------------------------------------------------------------------- //
// Path utilities for chroot functionality
// --------------------------------------------------------------------------- //
function normalizePath(path) {
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
}
function joinPaths(...paths) {
return normalizePath(paths.filter(p => p).join('/'));
}
function resolvePath(chroot, filepath) {
if (!chroot) return filepath;
// Remove leading slash from filepath if present
const cleanFilepath = filepath.startsWith('/') ? filepath.substring(1) : filepath;
// Join chroot and filepath
const resolved = joinPaths(chroot, cleanFilepath);
// Ensure it starts with /
return resolved.startsWith('/') ? resolved : '/' + resolved;
}
function unresolvePath(chroot, filepath) {
if (!chroot) return filepath;
const chrootPath = chroot.startsWith('/') ? chroot : '/' + chroot;
const cleanFilepath = filepath.startsWith('/') ? filepath : '/' + filepath;
if (cleanFilepath.startsWith(chrootPath)) {
const relativePath = cleanFilepath.substring(chrootPath.length);
return relativePath.startsWith('/') ? relativePath : '/' + relativePath;
}
return filepath;
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Patch text parser // Patch text parser
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
class Parser { class Parser {
constructor(current_files, lines) { constructor(current_files, lines, chroot = null) {
this.current_files = current_files; this.current_files = current_files;
this.lines = lines; this.lines = lines;
this.index = 0; this.index = 0;
this.patch = new Patch(); this.patch = new Patch();
this.fuzz = 0; this.fuzz = 0;
this.chroot = chroot;
} }
// ------------- low-level helpers -------------------------------------- // // ------------- low-level helpers -------------------------------------- //
@@ -186,43 +215,52 @@ class Parser {
// ---------- UPDATE ---------- // // ---------- UPDATE ---------- //
let path = this.read_str("*** Update File: "); let path = this.read_str("*** Update File: ");
if (path) { if (path) {
if (path in this.patch.actions) { // Resolve path with chroot
throw new DiffError(`Duplicate update for file: ${path}`); const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate update for file: ${resolvedPath}`);
} }
const move_to = this.read_str("*** Move to: "); const move_to = this.read_str("*** Move to: ");
if (!(path in this.current_files)) { if (!(resolvedPath in this.current_files)) {
throw new DiffError(`Update File Error - missing file: ${path}`); throw new DiffError(`Update File Error - missing file: ${resolvedPath}`);
} }
const text = this.current_files[path]; const text = this.current_files[resolvedPath];
const action = this._parse_update_file(text); const action = this._parse_update_file(text);
action.move_path = move_to || null; action.move_path = move_to ? resolvePath(this.chroot, move_to) : null;
this.patch.actions[path] = action; this.patch.actions[resolvedPath] = action;
continue; continue;
} }
// ---------- DELETE ---------- // // ---------- DELETE ---------- //
path = this.read_str("*** Delete File: "); path = this.read_str("*** Delete File: ");
if (path) { if (path) {
if (path in this.patch.actions) { // Resolve path with chroot
throw new DiffError(`Duplicate delete for file: ${path}`); const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate delete for file: ${resolvedPath}`);
} }
if (!(path in this.current_files)) { if (!(resolvedPath in this.current_files)) {
throw new DiffError(`Delete File Error - missing file: ${path}`); throw new DiffError(`Delete File Error - missing file: ${resolvedPath}`);
} }
this.patch.actions[path] = new PatchAction(ActionType.DELETE); this.patch.actions[resolvedPath] = new PatchAction(ActionType.DELETE);
continue; continue;
} }
// ---------- ADD ---------- // // ---------- ADD ---------- //
path = this.read_str("*** Add File: "); path = this.read_str("*** Add File: ");
if (path) { if (path) {
if (path in this.patch.actions) { // Resolve path with chroot
throw new DiffError(`Duplicate add for file: ${path}`); const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate add for file: ${resolvedPath}`);
} }
if (path in this.current_files) { if (resolvedPath in this.current_files) {
throw new DiffError(`Add File Error - file already exists: ${path}`); throw new DiffError(`Add File Error - file already exists: ${resolvedPath}`);
} }
this.patch.actions[path] = this._parse_add_file(); this.patch.actions[resolvedPath] = this._parse_add_file();
continue; continue;
} }
@@ -512,7 +550,7 @@ function _get_updated_file(text, action, path) {
return dest_lines.join("\n"); return dest_lines.join("\n");
} }
function patch_to_commit(patch, orig) { function patch_to_commit(patch, orig, chroot = null) {
const commit = new Commit(); const commit = new Commit();
for (const [path, action] of Object.entries(patch.actions)) { for (const [path, action] of Object.entries(patch.actions)) {
if (action.type === ActionType.DELETE) { if (action.type === ActionType.DELETE) {
@@ -534,11 +572,12 @@ function patch_to_commit(patch, orig) {
); );
} else if (action.type === ActionType.UPDATE) { } else if (action.type === ActionType.UPDATE) {
const new_content = _get_updated_file(orig[path], action, path); const new_content = _get_updated_file(orig[path], action, path);
const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null;
commit.changes[path] = new FileChange( commit.changes[path] = new FileChange(
ActionType.UPDATE, ActionType.UPDATE,
orig[path], orig[path],
new_content, new_content,
action.move_path move_path
); );
} }
} }
@@ -548,7 +587,7 @@ function patch_to_commit(patch, orig) {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// User-facing helpers // User-facing helpers
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
function text_to_patch(text, orig) { function text_to_patch(text, orig, chroot = null) {
// Handle different line ending scenarios // Handle different line ending scenarios
let lines = text.split(/\r?\n/); let lines = text.split(/\r?\n/);
@@ -574,13 +613,13 @@ function text_to_patch(text, orig) {
throw new DiffError("Invalid patch text - missing sentinels"); throw new DiffError("Invalid patch text - missing sentinels");
} }
const parser = new Parser(orig, lines); const parser = new Parser(orig, lines, chroot);
parser.index = 1; parser.index = 1;
parser.parse(); parser.parse();
return [parser.patch, parser.fuzz]; return [parser.patch, parser.fuzz];
} }
function identify_files_needed(text) { function identify_files_needed(text, chroot = null) {
// Handle line splitting consistently // Handle line splitting consistently
let lines = text.split(/\r?\n/); let lines = text.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") { if (lines.length > 0 && lines[lines.length - 1] === "") {
@@ -589,16 +628,16 @@ function identify_files_needed(text) {
const updateFiles = lines const updateFiles = lines
.filter(line => line.startsWith("*** Update File: ")) .filter(line => line.startsWith("*** Update File: "))
.map(line => line.substring("*** Update File: ".length)); .map(line => resolvePath(chroot, line.substring("*** Update File: ".length)));
const deleteFiles = lines const deleteFiles = lines
.filter(line => line.startsWith("*** Delete File: ")) .filter(line => line.startsWith("*** Delete File: "))
.map(line => line.substring("*** Delete File: ".length)); .map(line => resolvePath(chroot, line.substring("*** Delete File: ".length)));
return [...updateFiles, ...deleteFiles]; return [...updateFiles, ...deleteFiles];
} }
function identify_files_added(text) { function identify_files_added(text, chroot = null) {
// Handle line splitting consistently // Handle line splitting consistently
let lines = text.split(/\r?\n/); let lines = text.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") { if (lines.length > 0 && lines[lines.length - 1] === "") {
@@ -607,7 +646,7 @@ function identify_files_added(text) {
return lines return lines
.filter(line => line.startsWith("*** Add File: ")) .filter(line => line.startsWith("*** Add File: "))
.map(line => line.substring("*** Add File: ".length)); .map(line => resolvePath(chroot, line.substring("*** Add File: ".length)));
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -621,7 +660,7 @@ function load_files(paths, open_fn) {
return result; return result;
} }
function apply_commit(commit, write_fn, remove_fn) { function apply_commit(commit, write_fn, remove_fn, chroot = null) {
for (const [path, change] of Object.entries(commit.changes)) { for (const [path, change] of Object.entries(commit.changes)) {
if (change.type === ActionType.DELETE) { if (change.type === ActionType.DELETE) {
remove_fn(path); remove_fn(path);
@@ -634,7 +673,7 @@ function apply_commit(commit, write_fn, remove_fn) {
if (change.new_content === null) { if (change.new_content === null) {
throw new DiffError(`UPDATE change for ${path} has no new content`); throw new DiffError(`UPDATE change for ${path} has no new content`);
} }
const target = change.move_path || path; const target = change.move_path ? resolvePath(chroot, change.move_path) : path;
write_fn(target, change.new_content); write_fn(target, change.new_content);
if (change.move_path) { if (change.move_path) {
remove_fn(path); remove_fn(path);
@@ -643,15 +682,15 @@ function apply_commit(commit, write_fn, remove_fn) {
} }
} }
function process_patch(text, open_fn, write_fn, remove_fn) { function process_patch(text, open_fn, write_fn, remove_fn, chroot = null) {
if (!text.startsWith("*** Begin Patch")) { if (!text.startsWith("*** Begin Patch")) {
throw new DiffError("Patch text must start with *** Begin Patch"); throw new DiffError("Patch text must start with *** Begin Patch");
} }
const paths = identify_files_needed(text); const paths = identify_files_needed(text, chroot);
const orig = load_files(paths, open_fn); const orig = load_files(paths, open_fn);
const [patch, _fuzz] = text_to_patch(text, orig); const [patch, _fuzz] = text_to_patch(text, orig, chroot);
const commit = patch_to_commit(patch, orig); const commit = patch_to_commit(patch, orig, chroot);
apply_commit(commit, write_fn, remove_fn); apply_commit(commit, write_fn, remove_fn, chroot);
return "Done!"; return "Done!";
} }
@@ -683,13 +722,13 @@ function remove_file(filepath) {
export default { export default {
type: "function", type: "function",
name: "patch_files", name: "patch_files",
description: "Apply a unified diff patch to files within a chroot directory, with option to reverse the patch", description: "Apply a unified diff patch " + desc,
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
patch: { patch: {
type: "string", type: "string",
description: "The unidiff patch string to apply.", description: "The patch string to apply.",
} }
}, },
required: ["patch"], required: ["patch"],
@@ -706,7 +745,8 @@ export async function run(args) {
args.patch, args.patch,
open_file, open_file,
write_file, write_file,
remove_file remove_file,
'/home/seb/src/aiTools/tmp'
); );
return result; return result;
} catch (error) { } catch (error) {
@@ -715,4 +755,4 @@ export async function run(args) {
} }
throw error; throw error;
} }
} }