From 72aa872faaed4544ce62eb286131e6b62c344c96 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Fri, 16 Oct 2026 14:31:06 +0300 Subject: [PATCH 1/3] fix(workspace): glob and grep's glob filter take a mount-prefixed pattern Every workspace path is mount-prefixed, glob's own results included, but the pattern was matched only against paths relative to the mount (or to `path`), so `notes/diary/2026-10-13.md` answered "no matches" for a file that was there. When a pattern's first segment names a mount, its rest is now also matched within that mount; a pattern that names no mount matches as before, including a relative one whose first directory shares a mount's name. grep's `glob` filter takes the same two readings. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.fixed.md | 10 ++ src/modules/workspace/index.ts | 32 +++++-- test/workspace-glob-mount-prefix.test.ts | 95 +++++++++++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 changelog.d/glob-mount-prefixed-patterns.fixed.md create mode 100644 test/workspace-glob-mount-prefix.test.ts 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..414e798f9 --- /dev/null +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -0,0 +1,10 @@ +- `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; patterns that name no mount match exactly as before, including + a relative one whose first directory happens to share a mount's name. + `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same + way. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 3be26091f..8f219a0f5 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1167,11 +1167,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'], @@ -1189,7 +1190,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' }, }, @@ -2401,7 +2402,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 }> = []; @@ -2411,11 +2411,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) }); } } @@ -2444,7 +2445,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; @@ -2507,8 +2507,9 @@ export class WorkspaceModule implements Module { } const intents = this.intents.get(mount.config.name)!; + const fileGlobs = input.glob ? mountGlobs(input.glob, mount.config.name) : null; 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({ @@ -3167,6 +3168,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. */ diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts new file mode 100644 index 000000000..9631b2e77 --- /dev/null +++ b/test/workspace-glob-mount-prefix.test.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ +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) { + 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;', + }; + 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/docs/guide.md', 'project/notes/todo.md']); + assert.deepEqual(await glob({ pattern: 'project/*.md' }), ['project/README.md']); +}); + +test('relative patterns match as before, in every mount', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: '**/*.md' }), [ + 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.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("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 are unchanged. + 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', + ]); +}); From d00f40e669935723bf02f9b5c35c157d653a30bc Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Sat, 17 Oct 2026 02:15:16 -0400 Subject: [PATCH 2/3] fix(workspace): a `**/` in a glob matches zero or more directories globPartToRegex turned `**` into `.*`, so a `**/` needed at least one directory before what followed it: `project/src/**/*.ts` missed `project/src/main.ts`, and `**/*.md` missed the `.md` files at a mount's root. Both tools' descriptions give `**/` patterns as examples, and other glob tools read `**/` as zero or more directories. globPartToRegex now converts each glob token in one pass, through a table: a `**/` at the start of a path segment becomes `(?:.*/)?`, any other `**` is still `.*`, and `*`, `?` and the characters a regex treats specially are converted as before. globToRegex tells each part whether it starts a path segment, so a `**/` right after a brace group, or at the start of an alternative that doesn't start a segment, still needs a directory there. The fragment says so. Its sentence on patterns that name no mount now says that every pattern is still matched relative to each mount as well, which "match exactly as before" no longer covered. Tests: the two expectations that pinned the old reading now include the root files. A new test covers a mount-prefixed and a relative `**/`, a `**/` under `path`, and grep's glob filter, with a file one directory deeper than `project/src/main.ts` in the fixture. Another covers a `**/` right after a brace group. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.fixed.md | 10 ++++-- src/modules/workspace/index.ts | 25 +++++++++------ test/workspace-glob-mount-prefix.test.ts | 31 ++++++++++++++++--- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md index 414e798f9..0c53dceec 100644 --- a/changelog.d/glob-mount-prefixed-patterns.fixed.md +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -4,7 +4,11 @@ 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; patterns that name no mount match exactly as before, including - a relative one whose first directory happens to share a mount's name. + 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. + 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 8f219a0f5..05c8c4dfd 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -3193,11 +3193,14 @@ 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] === '{') { const closeIdx = pattern.indexOf('}', i); if (closeIdx > i) { const alternatives = pattern.slice(i + 1, closeIdx).split(','); - regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')'; + regex += '(?:' + alternatives.map(a => globPartToRegex(a, atSegmentStart)).join('|') + ')'; i = closeIdx + 1; continue; } @@ -3209,17 +3212,21 @@ function globToRegex(pattern: string): RegExp { i++; } if (chunk) { - regex += globPartToRegex(chunk); + regex += globPartToRegex(chunk, atSegmentStart); } } return new RegExp(`^${regex}$`); } -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 index 9631b2e77..e603f2b61 100644 --- a/test/workspace-glob-mount-prefix.test.ts +++ b/test/workspace-glob-mount-prefix.test.ts @@ -3,6 +3,8 @@ * 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'; @@ -14,7 +16,7 @@ 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) { +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 = { @@ -25,6 +27,8 @@ function setup(t: TestContext) { '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('/'); @@ -62,14 +66,15 @@ test('a mount-prefixed pattern naming one file finds it', async (t) => { 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/docs/guide.md', 'project/notes/todo.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 as before, in every mount', async (t) => { +test('relative patterns match relative to every mount', async (t) => { const { glob } = setup(t); assert.deepEqual(await glob({ pattern: '**/*.md' }), [ - 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'project/docs/guide.md', 'project/notes/todo.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 @@ -84,11 +89,27 @@ test('with a path, the pattern may be relative to it or mount-prefixed', async ( 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 are unchanged. + // 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', ]); From 92092ab26cbcb42c1787bc55ed88c92d9801908e Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Sat, 17 Oct 2026 15:53:34 -0400 Subject: [PATCH 3/3] fix(workspace): grep builds its glob filter before its pass, so one it refuses is refused first Since #439, globToRegex refuses a brace group it can't read. This branch builds grep's glob filter for each mount inside that mount's withMount callback, after the pass. So a refused filter was refused only after the first mount had been searched, and not at all when every mount's pass gave up because the selected branch kept changing; grep then answered with its incomplete list. On main, the filter was built, and refused, before any mount was searched. It's now built before the pass, as glob builds its own pattern. #439's refusal tests pass either way: that the refusal now comes first is by reading. --- src/modules/workspace/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 13068198a..be6ce7687 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -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/" @@ -2530,7 +2533,6 @@ export class WorkspaceModule implements Module { } const intents = this.intents.get(mount.config.name)!; - const fileGlobs = input.glob ? mountGlobs(input.glob, mount.config.name) : null; for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) { if (fileGlobs && !fileGlobs.relative.test(report.path) && fileGlobs.prefixed?.test(report.path) !== true) continue; const file = `${mount.config.name}/${report.path}`;