Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions changelog.d/glob-mount-prefixed-patterns.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 43 additions & 16 deletions src/modules/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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' },
},
Expand Down Expand Up @@ -2424,7 +2425,6 @@ export class WorkspaceModule implements Module {
}

private async handleGlob(input: GlobInput): Promise<ToolResult> {
const regex = globToRegex(input.pattern);
const matches: Array<Record<string, unknown>> = [];
const incomplete: Array<{ path: string; reason: string }> = [];

Expand All @@ -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) });
}
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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/"
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -3198,6 +3201,23 @@ function stateView(report: PathReport): Record<string, unknown> {
// 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
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -3244,7 +3267,7 @@ function globToRegex(pattern: string): RegExp {
i++;
}
if (chunk) {
regex += globPartToRegex(chunk);
regex += globPartToRegex(chunk, atSegmentStart);
}
}
return new RegExp(`^${regex}$`);
Expand All @@ -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<string, string> = { '**/': '(?:.*/)?', '**': '.*', '*': '[^/]*', '?': '[^/]' };

// 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}`);
}
116 changes: 116 additions & 0 deletions test/workspace-glob-mount-prefix.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
const root = mkdtempSync(join(tmpdir(), 'workspace-glob-prefix-'));
const dirs = { notes: join(root, 'notes'), project: join(root, 'project') };
const files: Record<string, string> = {
'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<string, unknown>) => {
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<string, unknown>) => {
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',
]);
});
Loading