diff --git a/changelog.d/glob-mount-prefixed-patterns.breaking.md b/changelog.d/glob-mount-prefixed-patterns.breaking.md new file mode 100644 index 000000000..52706ea4c --- /dev/null +++ b/changelog.d/glob-mount-prefixed-patterns.breaking.md @@ -0,0 +1,18 @@ +- **Agents, and whoever writes their prompts, where a mount holds a + directory named like the mount itself:** a `workspace--glob` pattern, or a + `workspace--grep` `glob` filter, whose first segment is that mount's name + is now read mount-prefixed in that mount, from its root and within + `path`, and no longer as written there. So a pattern written relative to + the mount, such as `work/z/*.ts` for the file the tools show as + `work/work/z/a.ts`, no longer finds that file. With a `path`, the same + goes for such a directory anywhere beneath it: `glob` with `path: + "notes/diary"` and `pattern: "notes/*.md"`, written relative to the path + for `notes/diary/notes/x.md`, no longer finds it. Where nothing else + matches, the agent gets an answer with no matches (`glob`'s + `"matches": []`, `grep`'s `"results": []`), not an error. + - **Migration:** write the path as the tools show it: `work/work/z/*.ts` + finds `work/work/z/a.ts`, and `notes/diary/notes/*.md` finds + `notes/diary/notes/x.md`, with that `path` or without one. + - **Unchanged:** in every other mount, and for a pattern whose first + segment names no mount, the pattern is still matched relative to the + mount (or to `path`). diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md new file mode 100644 index 000000000..b6ec9587a --- /dev/null +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -0,0 +1,22 @@ +- `workspace--glob` finds files from a mount-prefixed pattern, such as + `notes/diary/2026-10-13.md` or `project/src/**/*.ts`, as every other + workspace path is written and as its own results are returned. The pattern + was matched only against paths relative to the mount (or to `path`), so a + mount-prefixed one answered "no matches" for files that were there. When a + pattern's first segment names a mount, that mount now matches its rest from + the mount's root, in place of the pattern as written. Every other mount + still matches the pattern relative to the mount (or to `path`), so + `notes/*.md` still finds `project/notes/todo.md` beside the `notes` mount's + own files. In a mount holding a directory named like itself, this narrows + what a pattern written relative to the mount finds (see Breaking). + `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same + way. In both, a `**/` at the start of a path segment now matches zero or + more directories, as in other glob tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and + `**/*.md` also finds the `.md` files at a mount's root; it used to need at + least one directory there. After a brace group whose alternatives each end a + directory, a `**/` matches zero or more directories, and so does a `**` + that ends an alternative before a `/`, so `{src/,lib/}**/*.ts` and + `{src/**,lib}/*.ts` find `src/main.ts`, as `src/**/*.ts` does. `glob`'s description and its `pattern` + parameter's description changed, and so did `grep`'s `glob` parameter's + description, so the first request after the upgrade that carries the + workspace's tools misses the prompt cache from its tool block on. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 8be71e792..bf684a69c 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1851,11 +1851,12 @@ export class WorkspaceModule implements Module { }, { name: 'glob', - description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.' + globDirect, + description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes. ' + + 'The pattern is relative to path (or to each mount\'s root). In the mount its first segment names, it is read mount-prefixed instead, like the paths it returns, within path if one is given; in every other mount, as written.' + globDirect, inputSchema: { type: 'object' as const, properties: { - pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.ts")' }, + pattern: { type: 'string', description: 'Glob pattern, relative (e.g., "**/*.ts") or mount-prefixed (e.g., "project/src/**/*.ts")' }, path: { type: 'string', description: 'Directory to search in (mount-prefixed, optional)' }, }, required: ['pattern'], @@ -1873,7 +1874,7 @@ export class WorkspaceModule implements Module { properties: { pattern: { type: 'string', description: 'Regular expression pattern' }, path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' }, - glob: { type: 'string', description: 'Glob pattern to filter files' }, + glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts"); in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), and in every other mount, as written' }, contextBefore: { type: 'integer', description: 'Context lines before match' }, contextAfter: { type: 'integer', description: 'Context lines after match' }, }, @@ -4616,11 +4617,23 @@ export class WorkspaceModule implements Module { // Every match lies beneath the pattern's literal directory, so the walk // starts there: past the walk's cap, a walk of the whole scope can stop - // before it gets there. + // before it gets there. In the mount a pattern's first segment names, the + // pattern is read mount-prefixed (prefixedGlob), from its rest's directory + // within `path`. const patternDir = literalDirOf(input.pattern); for (const { mount, relativePath } of mountsToSearch) { - const start = await this.walkStart(mount, relativePath, patternDir); - const matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path); + const prefixed = prefixedGlob(input.pattern, mount.config.name); + let start: string; + let matched: (path: string) => boolean; + if (prefixed) { + const dir = deeperOf(relativePath, prefixed.dir); + if (dir === null) continue; + start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0)); + matched = (path: string) => prefixed.regex.test(path); + } else { + start = await this.walkStart(mount, relativePath, patternDir); + matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path); + } const listing = await this.listingOf(mount, start, true, matched); for (const { path, ...view } of listing.files) matches.push({ path: `${mount.config.name}/${path}`, ...view }); for (const region of listing.incomplete) { @@ -4651,7 +4664,8 @@ export class WorkspaceModule implements Module { const fileGlob = input.glob ? globToRegex(input.glob) : null; // The filter matches mount-relative paths, so every file it lets through // lies beneath its literal directory, and the walk starts at the deeper of - // that and `path`. + // that and `path`. In the mount a filter's first segment names, its + // mount-prefixed reading takes the filter's place (below). const filterDir = input.glob ? literalDirOf(input.glob) : ''; const contextBefore = input.contextBefore ?? 0; const contextAfter = input.contextAfter ?? 0; @@ -4699,16 +4713,21 @@ export class WorkspaceModule implements Module { incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) }); continue; } - // A `path` and a filter whose directories don't overlap leave no file - // the filter can let through, so nothing is searched. - const deeper = deeperOf(relativePath, filterDir); + // In the mount a filter's first segment names, the filter is read + // mount-prefixed (prefixedGlob), from its rest's directory. A `path` and + // a filter whose directories don't overlap leave no file the filter can + // let through, so nothing is searched. + const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null; + const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir); if (deeper === null) continue; const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0)); const searched = (text: GrepText): void => { const matches = search(text.text); if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches }); }; - const kept = (path: string): boolean => fileGlob === null || fileGlob.test(path); + const kept = prefixed + ? (path: string): boolean => prefixed.regex.test(path) + : (path: string): boolean => fileGlob === null || fileGlob.test(path); const found = mount.direct ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched) : await this.textsFromStore(mount, relativePath, start, kept, searched); @@ -5920,6 +5939,28 @@ function inputProblem( return null; } +/** + * A glob's reading in the mount its first segment names, in place of the + * pattern as written there. Every path the tools take and return is + * mount-prefixed, so `notes/diary/*.md` is the natural way to write a + * pattern, and tested as written against a mount-relative path it could + * never match — a glob answered "no matches" for files that were there. + * `regex` matches the rest of the pattern against the mount-relative path, + * and `dir` is the rest's literal directory (literalDirOf), from the mount's + * root. A pattern that names another mount, or none, has no such reading + * (null), and is read as written, relative to the search root. So in a mount + * holding a directory named like itself, `work/z/*.ts` names the mount's own + * `z`, and `work/work/z/*.ts`, as the tools show it, names the directory's. + * The rest's brace groups are the pattern's, so a pattern globToRegex took + * doesn't throw here. + */ +function prefixedGlob(pattern: string, mountName: string): { regex: RegExp; dir: string } | null { + const slash = pattern.indexOf('/'); + if (slash <= 0 || pattern.slice(0, slash) !== mountName) return null; + const rest = pattern.slice(slash + 1); + return { regex: globToRegex(rest), dir: literalDirOf(rest) }; +} + /** * Convert a glob pattern to a RegExp. Throws, naming the pattern, for a brace * group it can't read: a "{" that never closes, a group inside another, or a @@ -5954,9 +5995,14 @@ function globToRegex(pattern: string): RegExp { if (!group.includes(',')) { throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","')); } - const alternatives = group.split(','); - regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')'; - i = closeIdx + 1; + // A `/` right after the group goes into each alternative, as `{a,b}/x` + // and `{a/,b/}x` name the same paths, so an alternative's own `**` + // meets it and reads as a `**/` does, as in the group's expansion: + // `{src/**,lib}/*.ts` is `src/**/*.ts` or `lib/*.ts`. + const slash = pattern[closeIdx + 1] === '/'; + const alternatives = group.split(',').map((alternative) => (slash ? alternative + '/' : alternative)); + regex += '(?:' + alternatives.map(globPartToRegex).join('|') + ')'; + i = closeIdx + (slash ? 2 : 1); continue; } // Accumulate non-brace characters, convert as a chunk @@ -6002,11 +6048,19 @@ function braceRefusal(pattern: string, problem: string): string { return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`; } +// What each glob token becomes. A `**/` at the start of a path segment matches +// zero or more whole directories, so `src/**/*.ts` also matches `src/main.ts`; +// any other `**` matches any run of characters. +const GLOB_TOKENS: Record = { '**/': '(?:.*/)?', '**': '.*', '*': '[^/]*', '?': '[^/]' }; + +// One part of a pattern, between brace groups or inside one. Whether a `**/` +// at its start starts a path segment depends on what comes before it in the +// path, which after a brace group is whichever alternative matched: so it +// reads as GLOB_TOKENS' `**/` where the path so far is empty or ends with a +// `/`, as in `{src/,lib/}**/`, and otherwise as any run of characters and +// then a directory, as in `draft{s,ing}**/`. +const PART_START_GLOBSTAR = '(?:(?<=^|/)(?:.*/)?|(? + m === '**/' && offset === 0 ? PART_START_GLOBSTAR : GLOB_TOKENS[m] ?? `\\${m}`); } diff --git a/src/modules/workspace/types.ts b/src/modules/workspace/types.ts index cfd35adad..b1c97fe53 100644 --- a/src/modules/workspace/types.ts +++ b/src/modules/workspace/types.ts @@ -259,8 +259,12 @@ export interface MountConfig { * `glob`, `grep`) take up to it from their scope's start every time: * `glob`'s is the directory its pattern's segments before the first * wildcard name beneath its `path`, and `grep`'s the deeper of its `path` - * and its `glob` filter's directory, as far as disk lists each segment - * with that spelling as a directory, not a link. Each directory's listing + * and its `glob` filter's directory. For a pattern or filter whose first + * segment names the mount, either tool starts at the deeper of its `path` + * and the directory the rest names from the mount's root, and walks + * nothing where neither holds the other. Either way, the walk descends as + * far as disk lists each segment with that spelling as a directory, not a + * link. Each directory's listing * is read whole, so a deletion is seen wherever the walk lists, whatever * it took. * diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts new file mode 100644 index 000000000..e5aa4220a --- /dev/null +++ b/test/workspace-glob-mount-prefix.test.ts @@ -0,0 +1,181 @@ +/** + * glob and grep's glob filter take a mount-prefixed pattern, like every other + * workspace path. Before, the pattern was matched only against paths relative + * to the search root while results came back mount-prefixed, so + * `notes/diary/2026-10-13.md` answered "no matches" for a file that was there. + * A `**` path segment matches zero or more directories, so a pattern for every + * `.ts` file under `project/src` also finds `project/src/main.ts`. + */ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { WorkspaceModule } from '../src/modules/workspace/index.js'; + +/** Two mounts; `project` has a subdirectory named like the other mount. Files + * are written straight to disk, as another process would. */ +function setup(t: TestContext, extra: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'workspace-glob-prefix-')); + const dirs = { notes: join(root, 'notes'), project: join(root, 'project') }; + const files: Record = { + 'notes/README.md': 'notes readme', + 'notes/diary/2026-10-12.md': 'entry twelve', + 'notes/diary/2026-10-13.md': 'entry thirteen', + 'project/README.md': 'project readme', + 'project/docs/guide.md': 'guide entry', + 'project/notes/todo.md': 'todo entry', + 'project/src/main.ts': 'export const entry = 1;', + 'project/src/lib/util.ts': 'export const helper = 2;', + ...extra, + }; + for (const [path, content] of Object.entries(files)) { + const [mount, ...rest] = path.split('/'); + const full = join(dirs[mount as keyof typeof dirs], ...rest); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + const workspace = new WorkspaceModule({ + mounts: [ + { name: 'notes', path: dirs.notes, mode: 'read-write', watch: 'never' }, + { name: 'project', path: dirs.project, mode: 'read-write', watch: 'never' }, + ], + }); + workspace.initStore(store); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const glob = async (input: Record) => { + const r = await workspace.handleToolCall({ id: 'g', name: 'glob', input }); + assert.equal(r.success, true, JSON.stringify(r)); + return ((r.data as { matches: Array<{ path: string }> }).matches).map((m) => m.path).sort(); + }; + const grepFiles = async (input: Record) => { + const r = await workspace.handleToolCall({ id: 'r', name: 'grep', input }); + assert.equal(r.success, true, JSON.stringify(r)); + return ((r.data as { results: Array<{ file: string }> }).results).map((x) => x.file).sort(); + }; + return { glob, grepFiles }; +} + +test('a mount-prefixed pattern naming one file finds it', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: 'notes/diary/2026-10-13.md' }), ['notes/diary/2026-10-13.md']); +}); + +test('a mount-prefixed wildcard matches within that mount only', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); + assert.deepEqual(await glob({ pattern: 'project/**/*.md' }), ['project/README.md', 'project/docs/guide.md', 'project/notes/todo.md']); + assert.deepEqual(await glob({ pattern: 'project/*.md' }), ['project/README.md']); +}); + +test('relative patterns match relative to every mount', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: '**/*.md' }), [ + 'notes/README.md', 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', + 'project/README.md', 'project/docs/guide.md', 'project/notes/todo.md', + ]); + assert.deepEqual(await glob({ pattern: '*.md' }), ['notes/README.md', 'project/README.md']); + // In every other mount, a pattern whose first segment is a mount's name is + // read as written: project/notes/ matches, beside the notes mount's root. + assert.deepEqual(await glob({ pattern: 'notes/*.md' }), ['notes/README.md', 'project/notes/todo.md']); +}); + +test('with a path, the pattern may be relative to it or mount-prefixed', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: '*-13.md' }), ['notes/diary/2026-10-13.md']); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/diary/*-13.md' }), ['notes/diary/2026-10-13.md']); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'project/**' }), []); +}); + +test('a first segment that is a brace group or a wildcard names no mount', async (t) => { + // Read as written in every mount, so neither finds notes/diary's files, + // which notes/diary/*.md finds. + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: '{notes,project}/diary/*.md' }), []); + assert.deepEqual(await glob({ pattern: 'note?/diary/*.md' }), []); +}); + +test("with a path, a pattern naming the path's mount reads mount-prefixed, within the path", async (t) => { + const { glob, grepFiles } = setup(t, { 'notes/drafts/one.md': 'one', 'notes/diary/notes1': 'a note', 'notes/diary/notes/x.md': 'x' }); + const diary = ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'notes/diary/notes/x.md']; + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/**/*.md' }), diary); + // notes/README.md lies outside the path, and so does notes/drafts. And a + // pattern relative to the path that starts with the mount's name names the + // mount: notes/diary/notes/x.md is found by its path as the tools show it. + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/*.md' }), []); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/diary/notes/*.md' }), ['notes/diary/notes/x.md']); + assert.deepEqual(await glob({ pattern: 'notes/diary/notes/*.md' }), ['notes/diary/notes/x.md']); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/drafts/*.md' }), []); + assert.deepEqual(await grepFiles({ pattern: 'entry', path: 'notes/diary', glob: 'notes/diary/*-13.md' }), ['notes/diary/2026-10-13.md']); + assert.deepEqual(await grepFiles({ pattern: 'readme', glob: 'notes/*.md' }), ['notes/README.md']); + assert.deepEqual(await grepFiles({ pattern: 'readme', path: 'notes/diary', glob: 'notes/*.md' }), []); + // A pattern with no `/` names no mount, even one that starts with a + // mount's name, so it's read relative to the path. + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes1' }), ['notes/diary/notes1']); +}); + +test('a `**/` matches zero or more directories, in a relative or a mount-prefixed pattern', async (t) => { + const { glob, grepFiles } = setup(t); + const sources = ['project/src/lib/util.ts', 'project/src/main.ts']; + assert.deepEqual(await glob({ pattern: 'project/src/**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: 'src/**/*.ts' }), sources); + assert.deepEqual(await glob({ path: 'project/src', pattern: '**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: '**/README.md' }), ['notes/README.md', 'project/README.md']); + assert.deepEqual(await grepFiles({ pattern: 'export', glob: 'project/src/**/*.ts' }), sources); +}); + +test("a `**/` right after a brace group needs a directory there where the alternative before it doesn't end one", async (t) => { + // Not the start of a path segment, so the `**` is any run of characters. + const { glob } = setup(t, { 'notes/drafts.md': 'drafts', 'notes/drafts/one.md': 'one' }); + assert.deepEqual(await glob({ pattern: 'notes/draft{s,ing}**/*.md' }), ['notes/drafts/one.md']); +}); + +test('after a brace group, a `**/` matches zero or more directories after an alternative that ends one', async (t) => { + const { glob } = setup(t, { 'notes/draft.md': 'draft', 'notes/drafts/one.md': 'one', 'notes/ac.md': 'ac', 'notes/ab/c.md': 'abc', 'notes/ax/c.md': 'axc' }); + const sources = ['project/src/lib/util.ts', 'project/src/main.ts']; + assert.deepEqual(await glob({ pattern: 'project/{src/,docs/}**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: 'project/{src,docs}/**/*.ts' }), sources); + // Beside an alternative that doesn't end one, which still needs a directory. + assert.deepEqual(await glob({ pattern: 'notes/{diary/,draft}**/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'notes/drafts/one.md']); + // An empty alternative ends a directory only where the group starts one. + assert.deepEqual(await glob({ pattern: 'project/src/{,lib/}**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: 'notes/a{,b/}**/c.md' }), ['notes/ab/c.md', 'notes/ax/c.md']); +}); + +test("a `**/` that starts a brace alternative reads as the alternative's place in the path does", async (t) => { + const { glob } = setup(t, { 'notes/ac.md': 'ac', 'notes/ax/c.md': 'axc' }); + // At a segment's start: zero or more directories. + assert.deepEqual(await glob({ pattern: 'project/src/{**/,lib/}*.ts' }), ['project/src/lib/util.ts', 'project/src/main.ts']); + // Not at a segment's start: any run of characters, then a directory. + assert.deepEqual(await glob({ pattern: 'notes/a{**/,b/}c.md' }), ['notes/ax/c.md']); +}); + +test('a `**` that ends a brace alternative, with a `/` after the group, matches zero or more directories there, as the expansion does', async (t) => { + // `{src/**,docs}/*.ts` names what `src/**/*.ts` and `docs/*.ts` name. + const { glob } = setup(t, { 'project/docs/guide.ts': 'guide' }); + const all = ['project/docs/guide.ts', 'project/src/lib/util.ts', 'project/src/main.ts']; + assert.deepEqual(await glob({ pattern: 'project/{src/**,docs}/*.ts' }), all); + assert.deepEqual(await glob({ pattern: 'project/{src/**,docs/**}/*.ts' }), all); + // Without a `/` after the group, the `**` is any run of characters, as before. + assert.deepEqual(await glob({ pattern: 'project/{src/**,docs}.ts' }), ['project/src/lib/util.ts', 'project/src/main.ts']); +}); + +test("a pattern's other characters match themselves", async (t) => { + const { glob } = setup(t, { 'project/src/a+b.ts': 'plus', 'project/src/aaab.ts': 'decoy', 'project/src/a.b(c).ts': 'dots', 'project/src/axbc-ts': 'decoy', 'project/src/axb(c).ts': 'decoy' }); + assert.deepEqual(await glob({ pattern: 'project/src/a+b.ts' }), ['project/src/a+b.ts']); + assert.deepEqual(await glob({ pattern: 'project/src/a.b(c).ts' }), ['project/src/a.b(c).ts']); +}); + +test("grep's glob filter takes a mount-prefixed pattern", async (t) => { + const { grepFiles } = setup(t); + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'project/src/*.ts' }), ['project/src/main.ts']); + // In every other mount, a filter naming a mount is read as written. + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'notes/*.md' }), ['project/notes/todo.md']); + // Relative filters still match relative to each mount. + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: '**/*.md' }), [ + 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'project/docs/guide.md', 'project/notes/todo.md', + ]); +}); diff --git a/test/workspace-glob-unclosed-brace.test.ts b/test/workspace-glob-unclosed-brace.test.ts index 84d2ae797..296f01436 100644 --- a/test/workspace-glob-unclosed-brace.test.ts +++ b/test/workspace-glob-unclosed-brace.test.ts @@ -95,6 +95,8 @@ test('a "{" that never closes is named as that, with a closed group after it', ( test('glob and grep refuse a group inside another', () => { assertRefused('glob', { pattern: 'src/{a,{b,c}}.ts' }, 'src/{a,{b,c}}.ts', NESTED); assertRefused('grep', { pattern: 'export', glob: 'src/{a,{b,c}}.ts' }, 'src/{a,{b,c}}.ts', NESTED); + // …and so is one with a `/` after it, which an alternative would otherwise take in + assertRefused('glob', { pattern: '{a,{b/**,c}}/x' }, '{a,{b/**,c}}/x', NESTED); }); test('glob and grep refuse a group with no ",", an empty one included', () => { diff --git a/test/workspace-three-way.test.ts b/test/workspace-three-way.test.ts index 446e17e3e..810635f89 100644 --- a/test/workspace-three-way.test.ts +++ b/test/workspace-three-way.test.ts @@ -5991,6 +5991,13 @@ describe('a search starts at the directory its pattern names', () => { env.writeDisk('z/deep/y.ts', 'nothing here'); }; + // glob and grep each on a mount of its own, so neither answers from what + // the other's pass took into the store. + const eachOnItsOwn = async (t: TestContext, open: (env: Env) => Promise, globbed: [Record, string[]], grep: [Record, string[]]): Promise => { + assert.deepEqual(await matchesOf(await open(new Env(t)), globbed[0]), globbed[1], JSON.stringify(globbed[0])); + assert.deepEqual(await grepped(await open(new Env(t)), grep[0]), grep[1], JSON.stringify(grep[0])); + }; + // Each search runs on a mount of its own: one before it would have taken // z/deep's files in, and a walk reports what the store holds wherever it // stopped. @@ -6026,6 +6033,107 @@ describe('a search starts at the directory its pattern names', () => { } }); + // In the mount its first segment names, a pattern is read mount-prefixed, + // so its walk starts at z/deep: a walk from the root would stop at the cap + // and name regions outside z/deep. + test('past the walk cap, a mount-prefixed pattern walks from the directory the rest of it names, in glob and in grep\'s filter', async (t) => { + const outside = (regions: Array<{ path: string }> | undefined) => (regions ?? []).filter((r) => !r.path.startsWith('work/z/deep')); + // Where the mount also holds part of a directory named like itself, the + // pattern isn't read as written there, so nothing walks it. + for (const partial of [false, true]) { + const layout = (env: Env): void => { + pastTheCap(env); + if (partial) for (const f of ['work/z/1.txt', 'work/z/2.txt', 'work/z/3.txt']) env.writeDisk(f, 'filler'); + }; + const globs: Array<[Record, string[]]> = [ + [{ pattern: 'work/z/deep/*.ts' }, ['work/z/deep/x.ts', 'work/z/deep/y.ts']], + [{ pattern: 'work/z/deep/**/*.ts' }, ['work/z/deep/x.ts', 'work/z/deep/y.ts']], + [{ path: 'work/z', pattern: 'work/z/deep/*.ts' }, ['work/z/deep/x.ts', 'work/z/deep/y.ts']], + ]; + for (const [input, want] of globs) { + const env = new Env(t); + layout(env); + const m = await env.open({ walkCap: 2 }); + const found = await call(m, 'glob', input); + const what = JSON.stringify({ partial, ...input }); + assert.deepEqual(found.matches.map((match: { path: string }) => match.path), want, what); + assert.deepEqual(outside(found.incomplete), [], what); + } + const greps: Array<[Record, string[]]> = [ + [{ pattern: 'needle', glob: 'work/z/deep/*.ts' }, ['work/z/deep/x.ts']], + [{ pattern: 'needle', path: 'work/z', glob: 'work/z/deep/**' }, ['work/z/deep/x.ts']], + ]; + for (const [input, want] of greps) { + const env = new Env(t); + layout(env); + const m = await env.open({ walkCap: 2 }); + const searched = await call(m, 'grep', input); + const what = JSON.stringify({ partial, ...input }); + assert.deepEqual(searched.results.map((result: { file: string }) => result.file), want, what); + assert.deepEqual(outside(searched.incomplete), [], what); + } + } + }); + + // In a mount holding a directory named like itself, a pattern written + // relative to the mount, as main reads every pattern, now reads + // mount-prefixed: `work/z/deep/*.ts` names the mount's own z/deep, not + // work/z/deep inside it. The tools show that file as work/work/z/deep/q.ts, + // and that pattern finds it, past the cap too. + test('in a mount holding a directory named like itself, a pattern naming the mount reads mount-prefixed only', async (t) => { + for (const cap of [undefined, 2]) { + const layout = async (env: Env): Promise => { + if (cap) pastTheCap(env); else env.writeDisk('z/deep/x.ts', 'a needle'); + env.writeDisk('work/z/deep/q.ts', 'a needle'); + return env.open(cap ? { walkCap: cap } : {}); + }; + await eachOnItsOwn(t, layout, + [{ pattern: 'work/z/deep/*.ts' }, cap ? ['work/z/deep/x.ts', 'work/z/deep/y.ts'] : ['work/z/deep/x.ts']], + [{ pattern: 'needle', glob: 'work/z/deep/*.ts' }, ['work/z/deep/x.ts']]); + await eachOnItsOwn(t, layout, + [{ pattern: 'work/work/z/deep/*.ts' }, ['work/work/z/deep/q.ts']], + [{ pattern: 'needle', glob: 'work/work/z/deep/*.ts' }, ['work/work/z/deep/q.ts']]); + // With a path inside that directory, glob's pattern relative to the + // path reads as main reads it. grep's filter, which main reads relative + // to the mount, reads mount-prefixed there too, and names the mount's + // own z/deep, outside the path, so it lets nothing through. + await eachOnItsOwn(t, layout, + [{ path: 'work/work', pattern: 'z/deep/*.ts' }, ['work/work/z/deep/q.ts']], + [{ pattern: 'needle', path: 'work/work', glob: 'work/z/deep/*.ts' }, []]); + } + }); + + // A walk named at a directory sets aside the ignore patterns that cover it, + // so a mount-prefixed pattern finds what lies beneath an ignored directory + // it names: here node_modules, in a direct mount's default ignore list. + test('on a direct mount, a mount-prefixed pattern finds what lies beneath the ignored directory it names', async (t) => { + const env = new Env(t); + env.writeDisk('node_modules/pkg/b.js', 'a needle'); + env.writeDisk('work/node_modules/pkg/a.js', 'a needle'); + const m = await env.open({ backend: 'direct', mode: 'read-only' } as Partial); + assert.deepEqual(await matchesOf(m, { pattern: 'work/node_modules/pkg/*.js' }), ['work/node_modules/pkg/b.js']); + assert.deepEqual(await grepped(m, { pattern: 'needle', glob: 'work/node_modules/pkg/*.js' }), ['work/node_modules/pkg/b.js']); + assert.deepEqual(await matchesOf(m, { pattern: 'work/work/node_modules/pkg/*.js' }), ['work/work/node_modules/pkg/a.js']); + }); + + // A pass reports what the workspace tracks beneath its scope whatever disk + // shows, so a mount-prefixed pattern lists a draft written where disk has + // no directory. + test('a mount-prefixed pattern lists what the workspace holds where disk has no directory', async (t) => { + const want = ['work/z/deep/new.ts', 'work/z/deep/x.ts']; + await eachOnItsOwn(t, async (env) => { + env.writeDisk('z/deep/x.ts', 'a needle'); + const m = await env.open(); + await call(m, 'write', { path: 'work/z/deep/new.ts', content: 'a needle' }); + return m; + }, [{ pattern: 'work/z/deep/*.ts' }, want], [{ pattern: 'needle', glob: 'work/z/deep/*.ts' }, want]); + await eachOnItsOwn(t, async (env) => { + const m = await env.open(); + await call(m, 'write', { path: 'work/q/new.ts', content: 'a needle' }); + return m; + }, [{ pattern: 'work/q/*.ts' }, ['work/q/new.ts']], [{ pattern: 'needle', glob: 'work/q/*.ts' }, ['work/q/new.ts']]); + }); + // Each list is what a walk of the whole mount finds: the test passes on // the code before searches started at their pattern's directory too. test('within the cap, each pattern finds what a walk of the whole mount finds, and leaves nothing incomplete', async (t) => {