From 1b6fdd6aa6d901f981872eb3fe226699855981df Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sat, 14 Mar 2026 13:34:29 -0600 Subject: [PATCH] perf: add session-scoped directory listing cache to path resolution resolveDir(), resolveFile(), and resolveTaskFiles() call readdirSync() on every invocation with no caching. These are called dozens of times per dispatch cycle through resolveMilestonePath, resolveSliceFile, relTaskFile, etc. This adds a module-level Map cache for directory listings with an exported clearPathCache() function for invalidation at dispatch cycle boundaries and milestone transitions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/paths.ts | 39 ++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index c41ee596e..601e7e1d9 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -9,9 +9,40 @@ * via prefix matching, so existing projects work without migration. */ -import { readdirSync, existsSync } from "node:fs"; +import { readdirSync, existsSync, Dirent } from "node:fs"; import { join } from "node:path"; +// ─── Directory Listing Cache ────────────────────────────────────────────────── + +const dirEntryCache = new Map(); +const dirListCache = new Map(); + +function cachedReaddirWithTypes(dirPath: string): Dirent[] { + const cached = dirEntryCache.get(dirPath); + if (cached) return cached; + const entries = readdirSync(dirPath, { withFileTypes: true }); + dirEntryCache.set(dirPath, entries); + return entries; +} + +function cachedReaddir(dirPath: string): string[] { + const cached = dirListCache.get(dirPath); + if (cached) return cached; + const entries = readdirSync(dirPath); + dirListCache.set(dirPath, entries); + return entries; +} + +/** + * Clear the directory listing cache. + * Call after milestone transitions, file creation in planning directories, + * or at the start/end of a dispatch cycle. + */ +export function clearPathCache(): void { + dirEntryCache.clear(); + dirListCache.clear(); +} + // ─── Name Builders ───────────────────────────────────────────────────────── /** @@ -58,7 +89,7 @@ export function buildTaskFileName(taskId: string, suffix: string): string { export function resolveDir(parentDir: string, idPrefix: string): string | null { if (!existsSync(parentDir)) return null; try { - const entries = readdirSync(parentDir, { withFileTypes: true }); + const entries = cachedReaddirWithTypes(parentDir); // Exact match first (current convention: bare ID) const exact = entries.find(e => e.isDirectory() && e.name === idPrefix); if (exact) return exact.name; @@ -83,7 +114,7 @@ export function resolveFile(dir: string, idPrefix: string, suffix: string): stri if (!existsSync(dir)) return null; const target = `${idPrefix}-${suffix}.md`.toUpperCase(); try { - const entries = readdirSync(dir); + const entries = cachedReaddir(dir); // Direct match: ID-SUFFIX.md const direct = entries.find(e => e.toUpperCase() === target); if (direct) return direct; @@ -113,7 +144,7 @@ export function resolveTaskFiles(tasksDir: string, suffix: string): string[] { const currentPattern = new RegExp(`^T\\d+-${suffix}\\.md$`, "i"); // Legacy convention: T01-INSTALL-PACKAGES-PLAN.md const legacyPattern = new RegExp(`^T\\d+-.*-${suffix}\\.md$`, "i"); - return readdirSync(tasksDir) + return cachedReaddir(tasksDir) .filter(f => currentPattern.test(f) || legacyPattern.test(f)) .sort(); } catch {