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..0c53dceec --- /dev/null +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -0,0 +1,14 @@ +- `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, its rest is now also matched within + that mount. Every pattern is still matched relative to each mount (or to + `path`) as well, so `notes/*.md` still finds `project/notes/todo.md` beside + the `notes` mount's own files. + `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same + way. In both, a `**/` 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. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index ae6c172b6..be6ce7687 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1198,11 +1198,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.', + 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), or mount-prefixed like the paths it returns.', 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'], @@ -1220,7 +1221,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") or mount-prefixed (e.g., "project/src/**/*.ts")' }, contextBefore: { type: 'number', description: 'Context lines before match' }, contextAfter: { type: 'number', description: 'Context lines after match' }, }, @@ -2424,7 +2425,6 @@ export class WorkspaceModule implements Module { } private async handleGlob(input: GlobInput): Promise { - const regex = globToRegex(input.pattern); const matches: Array> = []; const incomplete: Array<{ path: string; reason: string }> = []; @@ -2434,11 +2434,12 @@ export class WorkspaceModule implements Module { : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' })); for (const { mount, relativePath } of mountsToSearch) { + const globs = mountGlobs(input.pattern, mount.config.name); const pass = await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true })); for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) { const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path; - if (regex.test(testPath)) { + if (globs.relative.test(testPath) || globs.prefixed?.test(report.path) === true) { matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) }); } } @@ -2467,7 +2468,6 @@ export class WorkspaceModule implements Module { return { success: false, error: `Invalid regex: ${input.pattern}`, isError: true }; } - const fileGlob = input.glob ? globToRegex(input.glob) : null; const contextBefore = input.contextBefore ?? 0; const contextAfter = input.contextAfter ?? 0; @@ -2498,6 +2498,9 @@ export class WorkspaceModule implements Module { }; for (const { mount, relativePath } of mountsToSearch) { + // Built before the pass, as glob builds its own, so a pattern globToRegex + // refuses is refused before any mount is searched. + const fileGlobs = input.glob ? mountGlobs(input.glob, mount.config.name) : null; await this.withMount(mount, async () => { // A `path` naming a single file greps just that file; otherwise it is a // directory prefix. (A file path once produced the prefix "notes.md/" @@ -2531,7 +2534,7 @@ export class WorkspaceModule implements Module { const intents = this.intents.get(mount.config.name)!; for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) { - if (fileGlob && !fileGlob.test(report.path)) continue; + if (fileGlobs && !fileGlobs.relative.test(report.path) && fileGlobs.prefixed?.test(report.path) !== true) continue; const file = `${mount.config.name}/${report.path}`; if (report.state === 'disk-only') { skipped.push({ @@ -3198,6 +3201,23 @@ function stateView(report: PathReport): Record { // Utilities // ========================================================================== +/** + * The two ways a glob can be written for one mount. `relative` is the pattern + * as given, matched relative to the search root as it always was. `prefixed` + * is set when the pattern's first segment is this mount's name: 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. It matches the rest of the pattern against the + * mount-relative path. A pattern that names another mount, or none, matches + * as before. + */ +function mountGlobs(pattern: string, mountName: string): { relative: RegExp; prefixed: RegExp | null } { + const slash = pattern.indexOf('/'); + const prefixed = slash > 0 && pattern.slice(0, slash) === mountName ? globToRegex(pattern.slice(slash + 1)) : null; + return { relative: globToRegex(pattern), prefixed }; +} + /** * 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 @@ -3208,6 +3228,9 @@ function globToRegex(pattern: string): RegExp { let regex = ''; let i = 0; while (i < pattern.length) { + // Whether the part that starts here starts a path segment (see + // globPartToRegex). + const atSegmentStart = i === 0 || pattern[i - 1] === '/'; if (pattern[i] === '{') { // A brace form this can't read is refused. A `{` that never closes, read // as a literal, would almost always answer with no matches, and with it @@ -3233,7 +3256,7 @@ function globToRegex(pattern: string): RegExp { 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('|') + ')'; + regex += '(?:' + alternatives.map(a => globPartToRegex(a, atSegmentStart)).join('|') + ')'; i = closeIdx + 1; continue; } @@ -3244,7 +3267,7 @@ function globToRegex(pattern: string): RegExp { i++; } if (chunk) { - regex += globPartToRegex(chunk); + regex += globPartToRegex(chunk, atSegmentStart); } } return new RegExp(`^${regex}$`); @@ -3255,11 +3278,15 @@ function braceRefusal(pattern: string, problem: string): string { return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`; } -function globPartToRegex(part: string): string { - return part - .replace(/[.+^$()|[\]\\]/g, '\\$&') - .replace(/\*\*/g, '__DOUBLESTAR__') - .replace(/\*/g, '[^/]*') - .replace(/__DOUBLESTAR__/g, '.*') - .replace(/\?/g, '[^/]'); +// 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. A part that +// doesn't start a path segment, such as one right after a brace group, reads +// a `**/` at its start as any run of characters and then a directory. +function globPartToRegex(part: string, atSegmentStart: boolean): string { + return part.replace(/(?<=^|\/)\*\*\/|\*\*|[*?]|[.+^$()|[\]\\]/g, (m, offset: number) => + m === '**/' && offset === 0 && !atSegmentStart ? '.*/' : GLOB_TOKENS[m] ?? `\\${m}`); } diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts new file mode 100644 index 000000000..e603f2b61 --- /dev/null +++ b/test/workspace-glob-mount-prefix.test.ts @@ -0,0 +1,116 @@ +/** + * 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']); + // A pattern whose first segment is a mount's name keeps its relative + // reading too: project/notes/ still 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 `**/` 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 still needs a directory there', 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("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']); + // 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', + ]); +});