Skip to content

Commit 05f0cd8

Browse files
committed
feat(core): depth-limited vault enumeration with listFolders
Add a shared BFS walker (walkVault) to core that both listMarkdownFiles and the new listFolders use. A configurable depthLimit (0 = unlimited) controls how many directory levels are traversed. The VSCode extension reads the limit from the new obsidianVFS.depthLimit setting (default 4, single source of truth in package.json) and passes it to Mount Folder, Mount Note, and Search Notes commands. Also replaces ~53 bare error-code string literals across 15 core files with ERR.* and ERRNO.* constants from types.ts; ErrorCode is now derived from the ERR object so new codes only need one addition. Versions: core/cli 0.2.1 → 0.3.0, vscode 0.3.2 → 0.3.3. Assisted-by: Claude
1 parent 12a418b commit 05f0cd8

29 files changed

Lines changed: 767 additions & 430 deletions

packages/claude-plugin/bundle/entry-expansion.mjs

Lines changed: 120 additions & 81 deletions
Large diffs are not rendered by default.

packages/claude-plugin/bundle/entry-subagent.mjs

Lines changed: 117 additions & 78 deletions
Large diffs are not rendered by default.

packages/claude-plugin/bundle/hook-handler.mjs

Lines changed: 115 additions & 76 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@obsidian-vfs/cli",
3-
"version": "0.2.1",
3+
"version": "0.3.0",
44
"type": "module",
55
"description": "CLI for Obsidian VFS — provision skills/agents, inspect vault resources",
66
"license": "Apache-2.0",

packages/core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ The `./testing` entry point provides test doubles for consumer packages:
3838
| Category | Symbols |
3939
|----------|---------|
4040
| Resolution | `resolveMention`, `resolveSkillMention`, `resolveWikilink`, `normalizeMention`, `parseSection` |
41-
| File I/O | `readVirtualFile`, `listMarkdownFiles`, `readDirectory` |
41+
| File I/O | `readVirtualFile`, `listMarkdownFiles`, `listFolders`, `readDirectory` |
4242
| CLI | `ObsidianCLI`, `resolveExecConfig`, `resolveCliPath` |
4343
| Content | `sliceContent`, `scrubWikilinks`, `processContent`, `resolveEmbeds`, `maskCodeRegions` |
4444
| Frontmatter | `extractFrontmatter`, `extractCuratedFrontmatter`, `remapModelLine`, `mapModelToClaude` |

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@obsidian-vfs/core",
3-
"version": "0.2.1",
3+
"version": "0.3.0",
44
"type": "module",
55
"description": "Core library for Obsidian VFS — vault resolution, content processing, path security",
66
"license": "Apache-2.0",

packages/core/src/content-slice.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { buildObsUri } from "./uri.js";
2+
import { ERR } from "./types.js";
23
import type { VFSResult } from "./types.js";
34

45
/**
@@ -46,7 +47,7 @@ export function sliceContent(markdown: string, heading: string): VFSResult<strin
4647

4748
return {
4849
ok: false,
49-
error: { code: "FILE_NOT_FOUND", message: `Section not found: ${heading}` },
50+
error: { code: ERR.FILE_NOT_FOUND, message: `Section not found: ${heading}` },
5051
};
5152
}
5253

@@ -79,7 +80,7 @@ export function processContent(markdown: string, options: ContentSliceOptions):
7980
if (options.vaultName === undefined) {
8081
return {
8182
ok: false,
82-
error: { code: "INVALID_URI", message: "vaultName is required when scrubbing wikilinks" },
83+
error: { code: ERR.INVALID_URI, message: "vaultName is required when scrubbing wikilinks" },
8384
};
8485
}
8586
result = scrubWikilinks(result, options.vaultName);

packages/core/src/exec.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFile as execFileCb } from "node:child_process";
22
import { promisify } from "node:util";
33

4+
import { ERR, ERRNO } from "./types.js";
45
import type { VFSResult } from "./types.js";
56
import { resolveCliPath } from "./resolve-cli-path.js";
67

@@ -61,20 +62,24 @@ export async function execCLI(
6162
if (err instanceof Error && err.name === "AbortError") {
6263
return {
6364
ok: false,
64-
error: { code: "TIMEOUT", message: `CLI timed out after ${options.timeoutMs}ms` },
65+
error: { code: ERR.TIMEOUT, message: `CLI timed out after ${options.timeoutMs}ms` },
6566
};
6667
}
67-
if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") {
68+
if (
69+
err instanceof Error &&
70+
"code" in err &&
71+
(err as NodeJS.ErrnoException).code === ERRNO.ENOENT
72+
) {
6873
return {
6974
ok: false,
7075
error: {
71-
code: "CLI_UNAVAILABLE",
76+
code: ERR.CLI_UNAVAILABLE,
7277
message: `CLI binary not found: ${options.cliPath}`,
7378
},
7479
};
7580
}
7681
const message = err instanceof Error ? err.message : String(err);
77-
return { ok: false, error: { code: "CLI_ERROR", message } };
82+
return { ok: false, error: { code: ERR.CLI_ERROR, message } };
7883
} finally {
7984
clearTimeout(timer);
8085
}

packages/core/src/fs-enumeration.test.ts

Lines changed: 155 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
import type { Dirent } from "node:fs";
32

4-
import { listMarkdownFiles, readDirectory, statVirtualFile } from "./fs-enumeration.js";
5-
import { mockFsFunction } from "./test-helpers.js";
3+
import { listFolders, listMarkdownFiles, readDirectory, statVirtualFile } from "./fs-enumeration.js";
4+
import { makeDirent, mockFsFunction } from "./test-helpers.js";
65

76
vi.mock("node:fs/promises", () => ({
87
readdir: vi.fn(),
@@ -17,21 +16,6 @@ const realpathMock = mockFsFunction(realpath);
1716

1817
const OPTIONS = { vaultRoot: "/vault", allowed: [] as string[], blocked: [] as string[] };
1918

20-
function makeDirent(name: string, isDir: boolean): Dirent {
21-
return {
22-
name,
23-
isDirectory: () => isDir,
24-
isFile: () => !isDir,
25-
isBlockDevice: () => false,
26-
isCharacterDevice: () => false,
27-
isFIFO: () => false,
28-
isSocket: () => false,
29-
isSymbolicLink: () => false,
30-
parentPath: "/vault",
31-
path: "/vault",
32-
};
33-
}
34-
3519
describe("readDirectory", () => {
3620
beforeEach(() => {
3721
vi.resetAllMocks();
@@ -142,20 +126,29 @@ describe("listMarkdownFiles", () => {
142126
});
143127

144128
it("enumerates markdown files sorted alphabetically", async () => {
145-
readdirMock.mockResolvedValueOnce(["b-note.md", "a-note.md", "image.png"]);
129+
readdirMock.mockResolvedValueOnce([
130+
makeDirent("b-note.md", false),
131+
makeDirent("a-note.md", false),
132+
makeDirent("image.png", false),
133+
]);
146134
const result = await listMarkdownFiles(OPTIONS);
147135
expect(result).toEqual({ ok: true, value: ["a-note.md", "b-note.md"] });
148136
});
149137

150138
it("skips entries with dot-prefixed path segments", async () => {
151-
readdirMock.mockResolvedValueOnce([".obsidian/plugins/note.md", "visible.md"]);
139+
readdirMock.mockResolvedValueOnce([
140+
makeDirent(".obsidian", true),
141+
makeDirent("visible.md", false),
142+
]);
152143
const result = await listMarkdownFiles(OPTIONS);
153144
expect(result).toEqual({ ok: true, value: ["visible.md"] });
154145
});
155146

156147
it("searches only allowed when specified", async () => {
157148
const options = { ...OPTIONS, allowed: ["notes", "docs"] };
158-
readdirMock.mockResolvedValueOnce(["intro.md"]).mockResolvedValueOnce(["guide.md"]);
149+
readdirMock
150+
.mockResolvedValueOnce([makeDirent("intro.md", false)])
151+
.mockResolvedValueOnce([makeDirent("guide.md", false)]);
159152
const result = await listMarkdownFiles(options);
160153
expect(result).toEqual({
161154
ok: true,
@@ -171,30 +164,167 @@ describe("listMarkdownFiles", () => {
171164

172165
it("skips unreadable directories", async () => {
173166
const options = { ...OPTIONS, allowed: ["bad", "good"] };
174-
readdirMock.mockRejectedValueOnce(new Error("ENOENT")).mockResolvedValueOnce(["note.md"]);
167+
readdirMock
168+
.mockRejectedValueOnce(new Error("ENOENT"))
169+
.mockResolvedValueOnce([makeDirent("note.md", false)]);
175170
const result = await listMarkdownFiles(options);
176171
expect(result).toEqual({ ok: true, value: ["good/note.md"] });
177172
});
178173

179174
it("handles nested subdirectories", async () => {
180-
readdirMock.mockResolvedValueOnce(["sub/deep/note.md", "top.md"]);
175+
readdirMock
176+
.mockResolvedValueOnce([makeDirent("sub", true), makeDirent("top.md", false)])
177+
.mockResolvedValueOnce([makeDirent("deep", true)])
178+
.mockResolvedValueOnce([makeDirent("note.md", false)]);
181179
const result = await listMarkdownFiles(OPTIONS);
182180
expect(result).toEqual({ ok: true, value: ["sub/deep/note.md", "top.md"] });
183181
});
184182

185183
it("excludes files in blocked folders", async () => {
186184
const options = { ...OPTIONS, allowed: ["notes"], blocked: ["notes/draft"] };
187-
readdirMock.mockResolvedValueOnce(["public/doc.md", "draft/wip.md"]);
185+
readdirMock
186+
.mockResolvedValueOnce([makeDirent("public", true), makeDirent("draft", true)])
187+
.mockResolvedValueOnce([makeDirent("doc.md", false)]);
188188
const result = await listMarkdownFiles(options);
189189
expect(result).toEqual({ ok: true, value: ["notes/public/doc.md"] });
190190
});
191191

192192
it("excludes blocked files without allowed", async () => {
193193
const options = { ...OPTIONS, blocked: ["private"] };
194-
readdirMock.mockResolvedValueOnce(["notes/doc.md", "private/secret.md"]);
194+
readdirMock
195+
.mockResolvedValueOnce([makeDirent("notes", true), makeDirent("private", true)])
196+
.mockResolvedValueOnce([makeDirent("doc.md", false)]);
195197
const result = await listMarkdownFiles(options);
196198
expect(result).toEqual({ ok: true, value: ["notes/doc.md"] });
197199
});
200+
201+
it("respects depthLimit parameter", async () => {
202+
readdirMock
203+
.mockResolvedValueOnce([makeDirent("sub", true), makeDirent("top.md", false)])
204+
.mockResolvedValueOnce([makeDirent("deep", true), makeDirent("mid.md", false)])
205+
.mockResolvedValueOnce([makeDirent("bottom.md", false)]);
206+
const result = await listMarkdownFiles(OPTIONS, 2);
207+
expect(result).toEqual({ ok: true, value: ["sub/mid.md", "top.md"] });
208+
});
209+
210+
it("treats depthLimit 0 as unlimited", async () => {
211+
readdirMock
212+
.mockResolvedValueOnce([makeDirent("sub", true)])
213+
.mockResolvedValueOnce([makeDirent("deep", true)])
214+
.mockResolvedValueOnce([makeDirent("note.md", false)]);
215+
const result = await listMarkdownFiles(OPTIONS, 0);
216+
expect(result).toEqual({ ok: true, value: ["sub/deep/note.md"] });
217+
});
218+
});
219+
220+
describe("listFolders", () => {
221+
beforeEach(() => {
222+
vi.resetAllMocks();
223+
});
224+
225+
it("enumerates folders recursively up to depthLimit", async () => {
226+
readdirMock
227+
.mockResolvedValueOnce([
228+
makeDirent("10-projects", true),
229+
makeDirent("20-areas", true),
230+
makeDirent("note.md", false),
231+
])
232+
.mockResolvedValueOnce([makeDirent("active", true), makeDirent("archive", true)])
233+
.mockResolvedValueOnce([]);
234+
const result = await listFolders(OPTIONS, 2);
235+
expect(result).toEqual({
236+
ok: true,
237+
value: ["10-projects", "10-projects/active", "10-projects/archive", "20-areas"],
238+
});
239+
});
240+
241+
it("depth 1 returns only top-level folders", async () => {
242+
readdirMock.mockResolvedValueOnce([
243+
makeDirent("10-projects", true),
244+
makeDirent("20-areas", true),
245+
makeDirent("note.md", false),
246+
]);
247+
const result = await listFolders(OPTIONS, 1);
248+
expect(result).toEqual({
249+
ok: true,
250+
value: ["10-projects", "20-areas"],
251+
});
252+
});
253+
254+
it("depth 0 returns unlimited (all folders)", async () => {
255+
readdirMock
256+
.mockResolvedValueOnce([makeDirent("a", true)])
257+
.mockResolvedValueOnce([makeDirent("b", true)])
258+
.mockResolvedValueOnce([makeDirent("c", true)])
259+
.mockResolvedValueOnce([]);
260+
const result = await listFolders(OPTIONS, 0);
261+
expect(result).toEqual({
262+
ok: true,
263+
value: ["a", "a/b", "a/b/c"],
264+
});
265+
});
266+
267+
it("filters dot-prefixed directories", async () => {
268+
readdirMock.mockResolvedValueOnce([
269+
makeDirent(".obsidian", true),
270+
makeDirent(".git", true),
271+
makeDirent("notes", true),
272+
]);
273+
const result = await listFolders(OPTIONS, 1);
274+
expect(result).toEqual({ ok: true, value: ["notes"] });
275+
});
276+
277+
it("enforces allowed list", async () => {
278+
const options = { ...OPTIONS, allowed: ["notes"] };
279+
readdirMock.mockResolvedValueOnce([makeDirent("sub", true)]).mockResolvedValueOnce([]);
280+
const result = await listFolders(options, 2);
281+
expect(result).toEqual({ ok: true, value: ["notes", "notes/sub"] });
282+
});
283+
284+
it("includes allowed roots themselves", async () => {
285+
const options = { ...OPTIONS, allowed: ["10-projects", "20-areas"] };
286+
readdirMock
287+
.mockResolvedValueOnce([makeDirent("calunga", true)])
288+
.mockResolvedValueOnce([makeDirent("career", true)]);
289+
const result = await listFolders(options, 1);
290+
expect(result).toEqual({
291+
ok: true,
292+
value: ["10-projects", "10-projects/calunga", "20-areas", "20-areas/career"],
293+
});
294+
});
295+
296+
it("enforces blocked list", async () => {
297+
const options = { ...OPTIONS, blocked: ["private"] };
298+
readdirMock.mockResolvedValueOnce([makeDirent("notes", true), makeDirent("private", true)]);
299+
const result = await listFolders(options, 1);
300+
expect(result).toEqual({ ok: true, value: ["notes"] });
301+
});
302+
303+
it("skips unreadable directories", async () => {
304+
const options = { ...OPTIONS, allowed: ["bad", "good"] };
305+
readdirMock
306+
.mockRejectedValueOnce(new Error("ENOENT"))
307+
.mockResolvedValueOnce([makeDirent("sub", true)])
308+
.mockResolvedValueOnce([]);
309+
const result = await listFolders(options, 2);
310+
expect(result).toEqual({ ok: true, value: ["bad", "good", "good/sub"] });
311+
});
312+
313+
it("returns empty array for empty vault", async () => {
314+
readdirMock.mockResolvedValueOnce([]);
315+
const result = await listFolders(OPTIONS, 1);
316+
expect(result).toEqual({ ok: true, value: [] });
317+
});
318+
319+
it("returns sorted results", async () => {
320+
readdirMock.mockResolvedValueOnce([
321+
makeDirent("zebra", true),
322+
makeDirent("alpha", true),
323+
makeDirent("mid", true),
324+
]);
325+
const result = await listFolders(OPTIONS, 1);
326+
expect(result).toEqual({ ok: true, value: ["alpha", "mid", "zebra"] });
327+
});
198328
});
199329

200330
describe("statVirtualFile", () => {

0 commit comments

Comments
 (0)