Merge pull request #398 from gsd-build/perf/path-resolution-cache

perf: session-scoped directory listing cache for path resolution
This commit is contained in:
TÂCHES 2026-03-14 14:36:59 -06:00 committed by GitHub
commit 86f705c8e4
5 changed files with 49 additions and 5 deletions

View file

@ -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<string, Dirent[]>();
const dirListCache = new Map<string, string[]>();
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 {

View file

@ -3,6 +3,7 @@ import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { createTestContext } from './test-helpers.ts';
import { clearPathCache } from '../paths.ts';
// loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout).
// In a worktree the file may not exist there yet, so we resolve prompts
@ -179,6 +180,7 @@ async function main(): Promise<void> {
// Now add the summary and verify it transitions to complete
writeMilestoneSummary(base, "M001", "# M001 Summary\n\nDone.");
clearPathCache();
const stateAfter = await deriveState(base);
assertEq(stateAfter.phase, "complete", "deriveState returns complete after summary exists");
assertEq(stateAfter.registry[0]?.status, "complete", "registry shows complete status");

View file

@ -3,7 +3,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { deriveState } from "../state.js";
import { resolveMilestoneFile } from "../paths.js";
import { resolveMilestoneFile, clearPathCache } from "../paths.js";
let passed = 0;
let failed = 0;
@ -40,6 +40,7 @@ assert(
const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md");
writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n");
clearPathCache();
const state2 = await deriveState(tmpBase);
assert(
state2.phase === "pre-planning",
@ -65,6 +66,7 @@ assert(
);
// Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists)
clearPathCache();
const state3 = await deriveState(tmpBase);
assert(
state3.phase === "pre-planning",

View file

@ -23,6 +23,7 @@ import {
parseSliceBranch,
switchToMain,
} from '../worktree.ts';
import { clearPathCache } from '../paths.ts';
import { createTestContext } from './test-helpers.ts';
// ─── Assertion Helpers ────────────────────────────────────────────────────
@ -437,6 +438,7 @@ Built the legacy feature successfully.
`);
run('git add .', base);
run('git commit -m add-m001', base);
clearPathCache();
// M001 (seq=1) < M001-abc123 (seq=1) — but M001 has incomplete S02
// Since M001 seq=1 and M002-abc123 seq=2, blocker should reference M001/S02
@ -459,6 +461,7 @@ Built the legacy feature successfully.
`);
run('git add .', base);
run('git commit -m complete-m001', base);
clearPathCache();
assertEq(
getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01'),

View file

@ -8,6 +8,7 @@ import {
readUnitRuntimeRecord,
writeUnitRuntimeRecord,
} from "../unit-runtime.ts";
import { clearPathCache } from '../paths.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
@ -48,6 +49,7 @@ console.log("\n=== execute-task durability inspection ===");
"utf-8",
);
writeFileSync(join(base, ".gsd", "STATE.md"), "## Next Action\nExecute T10 for S02: next thing\n", "utf-8");
clearPathCache();
status = await inspectExecuteTaskDurability(base, "M100/S02/T09");
assertEq(status!.summaryExists, true, "summary found after write");
@ -128,6 +130,7 @@ console.log("\n=== must-haves: partially mentioned in summary ===");
);
writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S02: next thing\n", "utf-8");
clearPathCache();
const status = await inspectExecuteTaskDurability(mhBase, "M200/S02/T01");
assertTrue(status !== null, "mh-partial: status exists");
assertEq(status!.mustHaveCount, 3, "mh-partial: mustHaveCount is 3");
@ -155,6 +158,7 @@ console.log("\n=== must-haves: no task plan file ===");
);
writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S03: next thing\n", "utf-8");
clearPathCache();
const status = await inspectExecuteTaskDurability(mhBase, "M200/S03/T01");
assertTrue(status !== null, "mh-noplan: status exists");
assertEq(status!.mustHaveCount, 0, "mh-noplan: mustHaveCount is 0 when no task plan");
@ -179,6 +183,7 @@ console.log("\n=== must-haves: present but no summary file ===");
);
writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T01 for S04: build parser\n", "utf-8");
clearPathCache();
const status = await inspectExecuteTaskDurability(mhBase, "M200/S04/T01");
assertTrue(status !== null, "mh-nosummary: status exists");
assertEq(status!.mustHaveCount, 2, "mh-nosummary: mustHaveCount is 2");
@ -210,6 +215,7 @@ console.log("\n=== must-haves: substring matching (no backtick tokens) ===");
);
writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S05: next thing\n", "utf-8");
clearPathCache();
const status = await inspectExecuteTaskDurability(mhBase, "M200/S05/T01");
assertTrue(status !== null, "mh-substr: status exists");
assertEq(status!.mustHaveCount, 3, "mh-substr: mustHaveCount is 3");