|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * LevelCode — AI: a shell command's file changes, routed through the SAME review as edit_file, |
| 3 | + * write_file and delete_file. |
| 4 | + * |
| 5 | + * A host whose shell is simulated (LevelCode in the browser) hands back, when a command ends, the files |
| 6 | + * it left changed. Each one that can be shown as text goes through ctx.applyEdit / ctx.applyDelete — |
| 7 | + * which is what gives a command Keep/Undo, a per-turn checkpoint (recordTouch) and a place in the |
| 8 | + * verify step (ctx.touched, ctx.editCount). What cannot be reviewed is applied by the shell itself and |
| 9 | + * said so, or refused and said so: the shell is never a way around what edit_file refuses. |
| 10 | + * |
| 11 | + * Pure and vscode-free, like verify.js and commandSafety.js, so it is unit-tested with plain node |
| 12 | + * (test/shellCommit.test.js). |
| 13 | + *--------------------------------------------------------------------------------------------*/ |
| 14 | +// @ts-check |
| 15 | +'use strict'; |
| 16 | + |
| 17 | +/** The folders agent.js's FILE_EXCLUDES leaves out of list_files and search: written, never reviewed. */ |
| 18 | +const EXCLUDED = /(^|\/)(node_modules|\.git|out|dist|\.vscode-test)(\/|$)|\.map$/; |
| 19 | +/** Larger than this is not shown as a diff card (snapshots are kept in workspaceState). */ |
| 20 | +const MAX_REVIEW_BYTES = 1024 * 1024; |
| 21 | +const utf8 = new TextDecoder('utf-8', { fatal: true }); |
| 22 | + |
| 23 | +/** A NUL in the first 8000 bytes is "binary" (host.isBinary); anything else must also decode as UTF-8. */ |
| 24 | +function isText(bytes) { |
| 25 | + if (!bytes) { return false; } |
| 26 | + const n = Math.min(bytes.length, 8000); |
| 27 | + for (let i = 0; i < n; i++) { if (bytes[i] === 0) { return false; } } |
| 28 | + try { utf8.decode(bytes); return true; } catch { return false; } |
| 29 | +} |
| 30 | +function same(a, b) { |
| 31 | + if (a === null || a === undefined) { return b === null || b === undefined; } |
| 32 | + if (b === null || b === undefined || a.length !== b.length) { return false; } |
| 33 | + for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } |
| 34 | + return true; |
| 35 | +} |
| 36 | + |
| 37 | +/** |
| 38 | + * The path a review call takes for an absolute path: relative to its workspace folder, and — with more than |
| 39 | + * one folder — prefixed with the folder's name, which is how resolveWorkspacePath finds the folder |
| 40 | + * deterministically (an unprefixed path means "the first folder"). |
| 41 | + * @param {string} abs |
| 42 | + * @param {Array<{name:string, root:string}>} folders |
| 43 | + * @returns {string|null} null when `abs` is in none of them |
| 44 | + */ |
| 45 | +function reviewPathFor(abs, folders) { |
| 46 | + let best = null; |
| 47 | + for (const f of folders) { |
| 48 | + const inside = abs === f.root || abs.startsWith(f.root === '/' ? '/' : f.root + '/'); |
| 49 | + if (inside && (!best || f.root.length > best.root.length)) { best = f; } |
| 50 | + } |
| 51 | + if (!best) { return null; } |
| 52 | + const sub = abs.slice(best.root === '/' ? 1 : best.root.length + 1); |
| 53 | + return folders.length > 1 ? best.name + '/' + sub : sub; |
| 54 | +} |
| 55 | + |
| 56 | +/** |
| 57 | + * @typedef {import('./host').Change} Change |
| 58 | + * @param {Change[]} changes |
| 59 | + * @param {{ applyEdit?: Function, applyDelete?: Function, touched?: Set<string>, editCount?: number }} ctx |
| 60 | + * @param {{ folders: () => Array<{name:string, root:string}>, readNow: (abs: string) => Promise<Uint8Array|null> }} io |
| 61 | + * @returns {Promise<{ rest: Change[], applied: Change[], failed: Array<{c: Change, why: string}>, notes: string[] }>} |
| 62 | + */ |
| 63 | +async function commitShellChanges(changes, ctx, io) { |
| 64 | + const folders = io.folders(); |
| 65 | + /** @type {Change[]} */ const rest = []; |
| 66 | + /** @type {Change[]} */ const applied = []; |
| 67 | + /** @type {Array<{c: Change, why: string}>} */ const failed = []; |
| 68 | + const touched = ctx.touched; |
| 69 | + const bump = () => { ctx.editCount = (ctx.editCount || 0) + 1; }; |
| 70 | + |
| 71 | + for (const c of changes.filter((x) => x.kind === 'write').sort((a, b) => (a.path < b.path ? -1 : 1))) { |
| 72 | + const rel = reviewPathFor(c.path, folders); |
| 73 | + if (rel === null) { failed.push({ c, why: 'outside the workspace folders' }); continue; } |
| 74 | + if (EXCLUDED.test(c.path)) { rest.push(c); continue; } |
| 75 | + const after = c.after || new Uint8Array(0); |
| 76 | + const reviewable = isText(after) && after.length <= MAX_REVIEW_BYTES |
| 77 | + && (!c.existed || (isText(c.before) && /** @type {Uint8Array} */ (c.before).length <= MAX_REVIEW_BYTES)); |
| 78 | + if (!reviewable) { |
| 79 | + if (c.existed) { failed.push({ c, why: 'it is binary or too large to review — edit it in the editor' }); } else { rest.push(c); } |
| 80 | + continue; |
| 81 | + } |
| 82 | + // Changed on disk while the command ran (the user saved, another tab wrote)? Do not overwrite it. |
| 83 | + const now = await io.readNow(c.path); |
| 84 | + if (!same(now, c.existed ? c.before : null)) { failed.push({ c, why: 'it changed while the command ran, so the command\'s result was not applied' }); continue; } |
| 85 | + if (typeof ctx.applyEdit !== 'function') { rest.push(c); continue; } |
| 86 | + const ok = await ctx.applyEdit({ path: rel, exists: !!c.existed, proposed: utf8.decode(after) }); |
| 87 | + if (ok) { bump(); if (touched) { touched.add(c.path); } applied.push(c); } |
| 88 | + else { failed.push({ c, why: 'the editor could not apply it' }); } |
| 89 | + } |
| 90 | + for (const c of changes.filter((x) => x.kind === 'delete').sort((a, b) => (a.path < b.path ? -1 : 1))) { |
| 91 | + const rel = reviewPathFor(c.path, folders); |
| 92 | + if (rel === null) { failed.push({ c, why: 'outside the workspace folders' }); continue; } |
| 93 | + if (EXCLUDED.test(c.path)) { rest.push(c); continue; } |
| 94 | + if (!isText(c.before) || /** @type {Uint8Array} */ (c.before).length > MAX_REVIEW_BYTES) { failed.push({ c, why: 'it is binary or too large to review — delete it in the editor' }); continue; } |
| 95 | + if (typeof ctx.applyDelete !== 'function') { rest.push(c); continue; } |
| 96 | + const ok = await ctx.applyDelete({ path: rel }); |
| 97 | + if (ok) { bump(); if (touched) { touched.delete(c.path); } applied.push(c); } |
| 98 | + else { failed.push({ c, why: 'the editor could not delete it' }); } |
| 99 | + } |
| 100 | + // A directory goes only when every file in it did. |
| 101 | + const blocked = new Set(failed.map((f) => f.c.viaDir).filter(Boolean)); |
| 102 | + for (const c of changes) { |
| 103 | + if (c.kind === 'mkdir') { rest.push(c); } |
| 104 | + else if (c.kind === 'rmdir' && !blocked.has(c.path)) { rest.push(c); } |
| 105 | + } |
| 106 | + |
| 107 | + /** @type {string[]} */ const notes = []; |
| 108 | + const sign = (c) => (c.kind === 'delete' ? '-' : c.existed ? '~' : '+'); |
| 109 | + if (applied.length) { |
| 110 | + const list = applied.slice(0, 8).map((c) => sign(c) + (reviewPathFor(c.path, folders) || c.path)).join(' '); |
| 111 | + notes.push('[LevelCode Shell: ' + applied.length + ' file' + (applied.length > 1 ? 's' : '') + ' changed, ready for Keep/Undo: ' + list + (applied.length > 8 ? ' …and ' + (applied.length - 8) + ' more' : '') + ']'); |
| 112 | + } |
| 113 | + const direct = rest.filter((c) => c.kind === 'write' || c.kind === 'delete').length; |
| 114 | + if (direct) { notes.push('[LevelCode Shell: ' + direct + ' file' + (direct > 1 ? 's' : '') + ' in build output or binary form were written without review]'); } |
| 115 | + for (const f of failed.slice(0, 5)) { notes.push('[LevelCode Shell: NOT applied: ' + (reviewPathFor(f.c.path, folders) || f.c.path) + ' — ' + f.why + ']'); } |
| 116 | + if (failed.length > 5) { notes.push('[LevelCode Shell: …and ' + (failed.length - 5) + ' more not applied]'); } |
| 117 | + return { rest, applied, failed, notes }; |
| 118 | +} |
| 119 | + |
| 120 | +module.exports = { commitShellChanges, reviewPathFor, isText, EXCLUDED, MAX_REVIEW_BYTES }; |
0 commit comments