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:
commit
86f705c8e4
5 changed files with 49 additions and 5 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue