diff --git a/.jules/bolt.md b/.jules/bolt.md index 5513142..e44fd9f 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -1,3 +1,7 @@ ## 2026-06-09 - Optimize File System Traversal **Learning:** Combining `fs.readdirSync` with `fs.statSync` for every file is a major performance bottleneck for local-first CLI tools that traverse many files. **Action:** Always prefer `fs.readdirSync(..., { withFileTypes: true })` to avoid redundant syscalls, which significantly speeds up directory traversal. + +## 2026-06-09 - Async Concurrent File Reads for CLI Lists +**Learning:** Sequential `readFileSync` calls block the Node main thread and can slow down list operations traversing many specs. +**Action:** Use `Promise.all` with `fs.promises.readFile` to process files concurrently. If reading thousands of files, employ a concurrency limit (e.g. chunking) to prevent EMFILE limits. diff --git a/src/cli.ts b/src/cli.ts index a6bb95a..32d1a69 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -307,9 +307,9 @@ function suggestDoctorActions(findings: { rule: string; message: string }[]) { return actions; } -function runCommand( - fn: () => T, - payload: (data: T) => { +async function runCommand( + fn: () => T | Promise, + payload: (data: Awaited) => { data: unknown; affectedFiles?: { path: string }[]; warnings?: { message: string }[]; @@ -317,7 +317,8 @@ function runCommand( }, ) { try { - outputSuccess(payload(fn())); + const result = (await fn()) as Awaited; + outputSuccess(payload(result)); } catch (error) { const info = errorInfo(error); outputError({ code: info.code, message: info.message, details: info.details, actions: info.actions }); diff --git a/src/entity-commands.ts b/src/entity-commands.ts index 1961e48..9b20e38 100644 --- a/src/entity-commands.ts +++ b/src/entity-commands.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, promises as fsPromises } from "node:fs"; import { basename, join } from "node:path"; import { orderActorFrontmatter, @@ -36,12 +36,22 @@ export function createActor(args: { name: string; displayName?: string; type?: s return { name, path: relativePath(path, root) }; } -export function listActors(args: { cwd?: string }) { +export async function listActors(args: { cwd?: string }) { const { root } = mustConfig(args.cwd); - return walkFiles(join(root, "specs/actors"), (path) => path.endsWith(".md")).map((path) => ({ - ...parseActorFrontmatter(parseMatter(readFileSync(path, "utf8")).data), - path: relativePath(path, root), - })); + const files = walkFiles(join(root, "specs/actors"), (path) => path.endsWith(".md")); + const results = []; + const chunkSize = 100; + for (let i = 0; i < files.length; i += chunkSize) { + const chunk = files.slice(i, i + chunkSize); + const parsedChunk = await Promise.all( + chunk.map(async (path) => ({ + ...parseActorFrontmatter(parseMatter(await fsPromises.readFile(path, "utf8")).data), + path: relativePath(path, root), + })) + ); + results.push(...parsedChunk); + } + return results; } export function showActor(args: { name: string; cwd?: string }) { @@ -105,12 +115,22 @@ export function createStakeholder(args: { name: string; displayName?: string; ty return { name, path: relativePath(path, root) }; } -export function listStakeholders(args: { cwd?: string }) { +export async function listStakeholders(args: { cwd?: string }) { const { root } = mustConfig(args.cwd); - return walkFiles(join(root, "specs/stakeholders"), (path) => path.endsWith(".md")).map((path) => ({ - ...parseStakeholderFrontmatter(parseMatter(readFileSync(path, "utf8")).data), - path: relativePath(path, root), - })); + const files = walkFiles(join(root, "specs/stakeholders"), (path) => path.endsWith(".md")); + const results = []; + const chunkSize = 100; + for (let i = 0; i < files.length; i += chunkSize) { + const chunk = files.slice(i, i + chunkSize); + const parsedChunk = await Promise.all( + chunk.map(async (path) => ({ + ...parseStakeholderFrontmatter(parseMatter(await fsPromises.readFile(path, "utf8")).data), + path: relativePath(path, root), + })) + ); + results.push(...parsedChunk); + } + return results; } export function showStakeholder(args: { name: string; cwd?: string }) { @@ -179,10 +199,22 @@ export function createGoal(args: { actor: string; description: string; level?: s return { id, path: relativePath(path, root) }; } -export function listGoals(args: { actor?: string; status?: string; cwd?: string }) { +export async function listGoals(args: { actor?: string; status?: string; cwd?: string }) { const { root } = mustConfig(args.cwd); - return walkFiles(join(root, "specs/goals"), (path) => path.endsWith(".md")) - .map((path) => ({ path, frontmatter: parseGoalFrontmatter(parseMatter(readFileSync(path, "utf8")).data) })) + const files = walkFiles(join(root, "specs/goals"), (path) => path.endsWith(".md")); + const results: { path: string; frontmatter: GoalFrontmatter }[] = []; + const chunkSize = 100; + for (let i = 0; i < files.length; i += chunkSize) { + const chunk = files.slice(i, i + chunkSize); + const parsedChunk = await Promise.all( + chunk.map(async (path) => ({ + path, + frontmatter: parseGoalFrontmatter(parseMatter(await fsPromises.readFile(path, "utf8")).data), + })) + ); + results.push(...parsedChunk); + } + return results .filter(({ frontmatter }) => !args.actor || frontmatter.actor === slugify(args.actor)) .filter(({ frontmatter }) => !args.status || frontmatter.status === args.status.toUpperCase()) .map(({ path, frontmatter }) => ({ ...frontmatter, path: relativePath(path, root) })); diff --git a/src/usecase-commands.ts b/src/usecase-commands.ts index 72e0367..32683d7 100644 --- a/src/usecase-commands.ts +++ b/src/usecase-commands.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, promises as fsPromises } from "node:fs"; import { join } from "node:path"; import { orderActorFrontmatter, @@ -84,11 +84,25 @@ export function createUseCase(args: { return { key, path: relativePath(path, root), format: "BRIEF" as const, affectedFiles }; } -export function listUseCases(args: { cwd?: string; status?: string; actor?: string; level?: string; q?: string }) { +export async function listUseCases(args: { cwd?: string; status?: string; actor?: string; level?: string; q?: string }) { const config = readConfig(args.cwd ?? process.cwd()); if (!config) throw new Error("NOT_INITIALIZED"); - return walkFiles(join(config.root, "specs/usecases"), (path) => path.endsWith(".md")) - .map((path) => ({ path, parsed: parseUseCaseMarkdown(readFileSync(path, "utf8")) })) + const files = walkFiles(join(config.root, "specs/usecases"), (path) => path.endsWith(".md")); + + const results: { path: string; parsed: ParsedUseCase }[] = []; + const chunkSize = 100; + for (let i = 0; i < files.length; i += chunkSize) { + const chunk = files.slice(i, i + chunkSize); + const parsedChunk = await Promise.all( + chunk.map(async (path) => ({ + path, + parsed: parseUseCaseMarkdown(await fsPromises.readFile(path, "utf8")), + })) + ); + results.push(...parsedChunk); + } + + return results .filter(({ parsed }) => !args.status || parsed.frontmatter.status === args.status.toUpperCase()) .filter(({ parsed }) => !args.actor || parsed.frontmatter.primary_actor === slugify(args.actor!)) .filter(({ parsed }) => !args.level || parsed.frontmatter.level === parseLevel(args.level!)) diff --git a/tests/authoring.test.ts b/tests/authoring.test.ts index 8e83335..fcedeb6 100644 --- a/tests/authoring.test.ts +++ b/tests/authoring.test.ts @@ -11,7 +11,7 @@ import { normalizeUseCaseMarkdown } from "../src/format/normalize.js"; import { runDoctor } from "../src/validate/doctor.js"; describe("use-case authoring loop", () => { - it("runs init -> usecase create -> round-trip -> doctor with no errors", () => { + it("runs init -> usecase create -> round-trip -> doctor with no errors", async () => { const root = join(tmpdir(), `vspec-authoring-${crypto.randomUUID()}`); mkdirSync(root, { recursive: true }); initProject({ root, key: "VSPEC" }); @@ -19,7 +19,7 @@ describe("use-case authoring loop", () => { const file = readFileSync(join(root, created.path), "utf8"); expect(serializeUseCase(parseUseCaseMarkdown(file))).toBe(normalizeUseCaseMarkdown(file)); expect(runDoctor({ root, target: created.key }).findings.filter((finding) => finding.level === "error")).toEqual([]); - expect(listUseCases({ cwd: root })).toHaveLength(1); + expect(await listUseCases({ cwd: root })).toHaveLength(1); expect(showUseCase({ cwd: root, key: created.key }).useCase.frontmatter.title).toBe("Author a use case"); rmSync(root, { recursive: true, force: true }); });