diff --git a/.jules/bolt.md b/.jules/bolt.md index 5513142..a9c8791 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 - Optimize I/O when reading multiple files +**Learning:** Using synchronous `readFileSync` loops for mapping large numbers of files significantly impacts performance by blocking the event loop and not exploiting concurrent I/O. +**Action:** Always prefer using `fs.promises.readFile` combined with `Promise.all` to read files concurrently in CLI list/walk commands. diff --git a/src/cli.ts b/src/cli.ts index a6bb95a..d9776f9 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,7 @@ function runCommand( }, ) { try { - outputSuccess(payload(fn())); + outputSuccess(payload((await fn()) as Awaited)); } 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..1aec2ab 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,10 +36,19 @@ 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), + const files = walkFiles(join(root, "specs/actors"), (path) => path.endsWith(".md")); + // ⚡ Bolt: Optimize I/O by reading files concurrently via Promise.all and fs.promises.readFile + // instead of mapping synchronously and blocking the event loop. + const parsedFiles = await Promise.all( + files.map(async (path) => ({ + path, + content: await fsPromises.readFile(path, "utf8"), + })) + ); + return parsedFiles.map(({ path, content }) => ({ + ...parseActorFrontmatter(parseMatter(content).data), path: relativePath(path, root), })); } @@ -105,10 +114,19 @@ 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), + const files = walkFiles(join(root, "specs/stakeholders"), (path) => path.endsWith(".md")); + // ⚡ Bolt: Optimize I/O by reading files concurrently via Promise.all and fs.promises.readFile + // instead of mapping synchronously and blocking the event loop. + const parsedFiles = await Promise.all( + files.map(async (path) => ({ + path, + content: await fsPromises.readFile(path, "utf8"), + })) + ); + return parsedFiles.map(({ path, content }) => ({ + ...parseStakeholderFrontmatter(parseMatter(content).data), path: relativePath(path, root), })); } @@ -179,10 +197,19 @@ 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")); + // ⚡ Bolt: Optimize I/O by reading files concurrently via Promise.all and fs.promises.readFile + // instead of mapping synchronously and blocking the event loop. + const parsedFiles = await Promise.all( + files.map(async (path) => ({ + path, + frontmatter: parseGoalFrontmatter(parseMatter(await fsPromises.readFile(path, "utf8")).data), + })) + ); + + return parsedFiles .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..51a3b70 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,20 @@ 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")); + // ⚡ Bolt: Optimize I/O by reading files concurrently via Promise.all and fs.promises.readFile + // instead of mapping synchronously and blocking the event loop. + const parsedFiles = await Promise.all( + files.map(async (path) => ({ + path, + parsed: parseUseCaseMarkdown(await fsPromises.readFile(path, "utf8")), + })) + ); + + return parsedFiles .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 }); });