Skip to content

Commit c1e7d46

Browse files
committed
fix(web/shell): #107 review — a command's change to the second of two folders called proj is not written into the first
The runtime tells same-named folders apart (planMounts mounts /a/proj and /b/proj each at its own root), but a command's change reaches the review as `name/rel` (reviewPathFor), and the review finds a folder by name: resolveWorkspacePathAsync takes the FIRST folder called proj. So a change to /b/proj/x.txt was compared with the right file (readNow reads /b/proj/x.txt) and written into /a/proj/x.txt; a new file was created there too. The same holds for two folders at one path — the scratch folder and a folder from the computer called scratch (`levelcode-scratch:/scratch`, `file:///scratch`), whose change paths name neither — and for a folder whose name has a slash in it, which the resolver reads as a path. The smallest correct fix is to refuse: the review and the file tools address a file by `name/rel`, and teaching them to take a folder is a change to the review that edit_file and the desktop share. commitShellChanges now refuses a change in a folder the review would not find again (folderMixUp): one that shares its root with another folder, whose name has a slash, or that is not the first folder of its name. The whole set is refused, as for every other refusal, and the note says why ('another workspace folder is also called "proj", and the review finds a folder by its name: it would apply this change there'). The first folder of a name is the one the review finds, so its changes go through as before; folders of distinct names, nested ones included, are unchanged. Tests: test/shellCommit.test.js "two folders the review cannot tell apart" (same name — a write, a delete and a set with a change in each folder —, same root, a slash in the name; the first folder of a name, a lone folder with a slash, and distinct nested names still applied). test/shellSeam.test.js "two workspace folders called proj", through the real commit hook, with a review stand-in that finds the file exactly as reviewSession.applyEdit does (resolveWorkspacePathAsync, then host.uriFor): on 187fd21 the two changes to b/proj are written to a/proj/new.txt and a/proj/x.txt; here nothing is written, and a change to a/proj still lands in a/proj. Both fail before and pass after; removing any one of the four checks fails one of them. (shellSeam's vscode stand-in gains workspace.fs.stat and FileType, which the resolver's existence check needs.)
1 parent 76a3b92 commit c1e7d46

3 files changed

Lines changed: 117 additions & 6 deletions

File tree

‎extensions/levelcode-ai/shellCommit.js‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
*
1919
* What is refused, and why:
2020
* - outside the workspace folders (a mount is the boundary)
21+
* - in a folder the review would not find again (two folders called proj, or at one path: the review
22+
* finds a folder by name and would write the other's)
2123
* - repository / tool metadata and instruction files (a script can hide `rm -rf .git`, or write
2224
* (in any letter case: .GIT is .git on most disks) .vscode/settings.json or AGENTS.md, which later
2325
* steer the agent with the user's authority)
@@ -96,6 +98,22 @@ function eolStyle(text) {
9698
/** A lone CR (old Mac) is not a line break the editor keeps: refuse it with the same reason as mixed. */
9799
function hasLoneCr(text) { return /\r(?!\n)/.test(text); }
98100

101+
/**
102+
* The workspace folder an absolute path is in: the deepest one, as host.uriFor picks it. Null when it is in none.
103+
* @template {{name:string, root:string}} F
104+
* @param {string} abs
105+
* @param {F[]} folders
106+
* @returns {F|null}
107+
*/
108+
function folderOf(abs, folders) {
109+
/** @type {F|null} */ let best = null;
110+
for (const f of folders) {
111+
const inside = abs === f.root || abs.startsWith(f.root === '/' ? '/' : f.root + '/');
112+
if (inside && (!best || f.root.length > best.root.length)) { best = f; }
113+
}
114+
return best;
115+
}
116+
99117
/**
100118
* The path a review call takes for an absolute path: relative to its workspace folder, and — with more than
101119
* one folder — prefixed with the folder's name, which is how resolveWorkspacePath finds the folder
@@ -105,16 +123,32 @@ function hasLoneCr(text) { return /\r(?!\n)/.test(text); }
105123
* @returns {{ review: string, rel: string }|null} null when `abs` is in none of them
106124
*/
107125
function reviewPathFor(abs, folders) {
108-
let best = null;
109-
for (const f of folders) {
110-
const inside = abs === f.root || abs.startsWith(f.root === '/' ? '/' : f.root + '/');
111-
if (inside && (!best || f.root.length > best.root.length)) { best = f; }
112-
}
126+
const best = folderOf(abs, folders);
113127
if (!best) { return null; }
114128
const rel = abs.slice(best.root === '/' ? 1 : best.root.length + 1);
115129
return { rel, review: folders.length > 1 ? best.name + '/' + rel : rel };
116130
}
117131

132+
/**
133+
* Why the review could apply a change in `folder` to ANOTHER folder's file, or null when it cannot. The review finds a
134+
* file by the path reviewPathFor makes: the folder by the name the path starts with — the first folder of that name, as
135+
* resolveWorkspacePath finds it — then the file by its absolute path (host.uriFor: the first folder at that root). So
136+
* with two clones called proj (/a/proj, /b/proj) a change to the second was compared with the right file (readNow)
137+
* and written into the first. A runtime that tells same-named folders apart (planMounts) hands back paths, and the
138+
* review takes names: rather than teach the review — which the file tools share — to take a folder, the change is
139+
* refused. The first folder of a name is the one the review finds, so its changes go through as before.
140+
* @param {{name:string, root:string}} folder
141+
* @param {Array<{name:string, root:string}>} folders
142+
* @returns {string|null}
143+
*/
144+
function folderMixUp(folder, folders) {
145+
if (folders.some((f) => f !== folder && f.root === folder.root)) { return 'two workspace folders are at ' + folder.root + ', and a path cannot say which of them it is in'; }
146+
if (folders.length < 2) { return null; }
147+
if (folder.name.split(/[\\/]/)[0] !== folder.name) { return 'the name of its workspace folder, "' + folder.name + '", has a slash in it, and the review would read it as a path'; }
148+
if (folders.find((f) => f.name === folder.name) !== folder) { return 'another workspace folder is also called "' + folder.name + '", and the review finds a folder by its name: it would apply this change there'; }
149+
return null;
150+
}
151+
118152
/**
119153
* A name as a disk that ignores case sees it. The usual folder from the computer is on one (APFS on a Mac, NTFS on
120154
* Windows), and there `.GIT/config` IS `.git/config`: matched as typed, a command wrote the repository's config by
@@ -164,6 +198,8 @@ async function commitShellChanges(changes, ctx, io) {
164198
for (const c of files) {
165199
const p = reviewPathFor(c.path, folders);
166200
if (p === null) { refusals.push({ c, why: 'it is outside the workspace folders' }); continue; }
201+
const mixUp = folderMixUp(/** @type {{name:string, root:string}} */ (folderOf(c.path, folders)), folders);
202+
if (mixUp) { refusals.push({ c, why: mixUp }); continue; }
167203
if (isProtected(p.rel)) { refusals.push({ c, why: 'repository metadata, tool settings and instruction files are not changed by commands — the user does that' }); continue; }
168204
const key = c.path.toLowerCase();
169205
if (seenLower.has(key) && seenLower.get(key) !== c.path) { refusals.push({ c, why: 'it differs from ' + seenLower.get(key) + ' only in letter case, which a case-insensitive disk cannot keep apart' }); continue; }

‎extensions/levelcode-ai/test/shellCommit.test.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,43 @@ const ids = (out) => out.failed.map((f) => f.c.path).sort();
4747
assert.strictEqual(reviewPathFor('/ws/sub/x', [{ name: 'ws', root: '/ws' }, { name: 'sub', root: '/ws/sub' }]).review, 'sub/x', 'the deepest folder wins, as host.uriFor does');
4848
});
4949

50+
await test('two folders the review cannot tell apart: a change it would apply to the other one is refused, and the whole set with it', async () => {
51+
// The review finds a folder by the name a path starts with (the first folder of that name, as resolveWorkspacePath
52+
// does), then a file by its absolute path (the first folder at that root, as host.uriFor does).
53+
const twins = [{ name: 'proj', root: '/a/proj' }, { name: 'proj', root: '/b/proj' }];
54+
let r = rig({ '/b/proj/x.txt': 'old', '/a/proj/y.txt': 'y' }, { folders: twins });
55+
let out = await commitShellChanges([W('/b/proj/x.txt', 'new', 'old'), W('/a/proj/y.txt', 'y2', 'y')], r.ctx, r.io);
56+
assert.strictEqual(out.refused, true);
57+
assert.deepStrictEqual(r.edits, [], 'proj/x.txt would have been written into /a/proj');
58+
assert.deepStrictEqual(ids(out), ['/b/proj/x.txt']);
59+
assert.match(out.notes[0], /refused: proj\/x\.txt — another workspace folder is also called "proj"/);
60+
r = rig({ '/b/proj/gone.txt': 'g' }, { folders: twins });
61+
assert.strictEqual((await commitShellChanges([D('/b/proj/gone.txt', 'g')], r.ctx, r.io)).refused, true, 'a delete too');
62+
assert.deepStrictEqual(r.deletes, []);
63+
// The first folder of the name is the one the review finds: its changes are applied, to it.
64+
r = rig({ '/a/proj/y.txt': 'y' }, { folders: twins });
65+
out = await commitShellChanges([W('/a/proj/y.txt', 'y2', 'y')], r.ctx, r.io);
66+
assert.strictEqual(out.refused, false);
67+
assert.deepStrictEqual(r.edits, [{ path: 'proj/y.txt', exists: true, proposed: 'y2' }]);
68+
// Two folders at one path (the scratch folder, and a folder from the computer called scratch): a path names neither.
69+
r = rig({ '/scratch/x.txt': 'old' }, { folders: [{ name: 'scratch', root: '/scratch' }, { name: 'mine', root: '/scratch' }] });
70+
out = await commitShellChanges([W('/scratch/x.txt', 'new', 'old')], r.ctx, r.io);
71+
assert.strictEqual(out.refused, true);
72+
assert.match(out.notes[0], /two workspace folders are at \/scratch/);
73+
// A folder whose name has a slash in it: the review reads the name as a path and finds another folder, or none.
74+
r = rig({}, { folders: [{ name: 'web', root: '/web' }, { name: 'a/b', root: '/ab' }] });
75+
out = await commitShellChanges([W('/ab/x.txt', 'new')], r.ctx, r.io);
76+
assert.strictEqual(out.refused, true);
77+
assert.match(out.notes[0], /"a\/b", has a slash in it/);
78+
r = rig({}, { folders: [{ name: 'a/b', root: '/ab' }] });
79+
assert.strictEqual((await commitShellChanges([W('/ab/x.txt', 'new')], r.ctx, r.io)).refused, false, 'alone, a folder is found without its name');
80+
// Folders of distinct names, nested ones included, are as they were.
81+
r = rig({}, { folders: [{ name: 'ws', root: '/ws' }, { name: 'sub', root: '/ws/sub' }, { name: 'lib', root: '/lib' }] });
82+
out = await commitShellChanges([W('/ws/sub/a.txt', 'a'), W('/lib/b.txt', 'b'), W('/ws/c.txt', 'c')], r.ctx, r.io);
83+
assert.strictEqual(out.refused, false);
84+
assert.deepStrictEqual(r.edits.map((e) => e.path), ['lib/b.txt', 'ws/c.txt', 'sub/a.txt']);
85+
});
86+
5087
await test('text changes are reviewed; edit and delete counts and ctx.touched follow; the files named in the note', async () => {
5188
const r = rig({ '/ws/a.txt': 'old', '/ws/b.txt': 'bye' });
5289
const out = await commitShellChanges([W('/ws/new.txt', 'hi\n'), W('/ws/a.txt', 'new', 'old'), D('/ws/b.txt', 'bye')], r.ctx, r.io);

‎extensions/levelcode-ai/test/shellSeam.test.js‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ const folder = (name, p) => ({ name, uri: { fsPath: p, path: p, toString: () =>
2828
const vscodeMock = {
2929
workspace: { workspaceFolders: [folder('app', root)], findFiles: async () => [], asRelativePath: (u) => String(u.fsPath || u),
3030
// the browser build reads through vscode.workspace.fs; here that is the temp directory
31-
fs: { readFile: async (u) => fs.promises.readFile(u.fsPath) } },
31+
fs: { readFile: async (u) => fs.promises.readFile(u.fsPath),
32+
stat: async (u) => { const s = await fs.promises.stat(u.fsPath); return { type: s.isDirectory() ? 2 : 1, size: s.size }; } } },
33+
FileType: { File: 1, Directory: 2 },
3234
env: { appRoot: '' },
3335
Uri: { file: (p) => ({ fsPath: p, path: p }) },
3436
extensions: { getExtension: () => undefined },
@@ -262,6 +264,42 @@ function ctxFor(over) {
262264
await p;
263265
});
264266

267+
await test('BROWSER, two workspace folders called proj: a command\'s change to the second is never written into the first', async () => {
268+
const { agent, host } = load(true);
269+
const calls = registerFake(host);
270+
const enc = (s) => new TextEncoder().encode(s);
271+
const a = path.join(root, 'a', 'proj'), b = path.join(root, 'b', 'proj');
272+
fs.mkdirSync(a, { recursive: true }); fs.mkdirSync(b, { recursive: true });
273+
fs.writeFileSync(path.join(a, 'x.txt'), 'A\n'); fs.writeFileSync(path.join(b, 'x.txt'), 'B\n');
274+
const saved = vscodeMock.workspace.workspaceFolders;
275+
vscodeMock.workspace.workspaceFolders = [folder('proj', a), folder('proj', b)];
276+
try {
277+
// What reviewSession.applyEdit / applyDelete do to find the file they change: the same resolver, then host.uriFor.
278+
const written = [];
279+
const find = async (r, mustExist) => { const abs = await agent.resolveWorkspacePathAsync(r.path, { mustExist }); if (abs) { written.push(host.uriFor(abs).fsPath); } return !!abs; };
280+
script = [tool('t1', 'run_command', { command: 'make', explanation: 'x' }), say('Done: x')];
281+
const p = agent.runAgent(ctxFor({ applyEdit: (r) => find(r, !!r.exists), applyDelete: (r) => find(r, true) }));
282+
await settle(); await settle();
283+
const commit = calls[0].opts.commit;
284+
// readNow compares b/proj/x.txt with what the command read (B), which is right: the write is what went wrong.
285+
const out = await commit([
286+
{ kind: 'write', path: b + '/x.txt', existed: true, before: enc('B\n'), after: enc('B2\n') },
287+
{ kind: 'write', path: b + '/new.txt', existed: false, before: null, after: enc('n\n') },
288+
]);
289+
assert.deepStrictEqual(written, [], 'nothing is written — and nothing into ' + a);
290+
assert.strictEqual(out.refused, true);
291+
assert.match(out.notes.join('\n'), /refused: proj\/x\.txt — another workspace folder is also called "proj"/);
292+
// The first folder of the name is the one the review finds: a change to it is applied, there.
293+
const ok = await commit([{ kind: 'write', path: a + '/x.txt', existed: true, before: enc('A\n'), after: enc('A2\n') }]);
294+
assert.strictEqual(ok.refused, false);
295+
assert.deepStrictEqual(written, [a + '/x.txt']);
296+
calls[0].child.emit('close', 0);
297+
await p;
298+
} finally {
299+
vscodeMock.workspace.workspaceFolders = saved;
300+
}
301+
});
302+
265303
await test('BROWSER, the shell says the run was stopped while its changes were being checked: the commit hook applies nothing', async () => {
266304
const { agent, host } = load(true);
267305
const calls = registerFake(host);

0 commit comments

Comments
 (0)