Skip to content

Commit e8e980b

Browse files
committed
[kensai] defer line counting from index build to manifest
PathIndex walk now does stat-only -- no file opens, no byte reads. Line counting and binary content detection move to buildManifest(), which runs only for changed files (~10-50). 172K-file repo: 22s -> 1s for index build. Other changes in this commit: - readFile falls through to direct fs read when file is not in the index (supports reading outside indexed paths) - buildManifest runs in parallel with fetchDiffs and resolveInstructions - Pool class and #countLines removed; standalone countFileLines() exported for on-demand use
1 parent 2328b16 commit e8e980b

4 files changed

Lines changed: 121 additions & 128 deletions

File tree

claude/kensai/mcp/review/src/repofs/pathindex/pathindex.test.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
44

55
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
66

7-
import { PathIndex, validateGlobs } from "./pathindex.ts";
7+
import { PathIndex, countFileLines, validateGlobs } from "./pathindex.ts";
88
import type { IndexEntryDir, IndexEntryFile, IndexEntrySymlink } from "./pathindex.ts";
99

1010
describe("PathIndex", () => {
@@ -321,32 +321,21 @@ describe("PathIndex.new", () => {
321321
expect(ix.get("nonexistent")).toBeUndefined();
322322
});
323323

324-
it("counts lines in text files", async () => {
324+
it("index is stat-only — lineCount and maxLineLen are zero", async () => {
325325
await writeFile(join(tempDir, "three.txt"), "one\ntwo\nthree\n");
326-
await writeFile(join(tempDir, "no-trailing.txt"), "one\ntwo");
327326
const ix = await PathIndex.new(tempDir);
328-
const three = ix.get("three.txt") as IndexEntryFile;
329-
const noTrailing = ix.get("no-trailing.txt") as IndexEntryFile;
330-
expect(three.lineCount).toBe(3);
331-
expect(three.isBinary).toBe(false);
332-
expect(noTrailing.lineCount).toBe(2);
333-
expect(noTrailing.isBinary).toBe(false);
334-
});
335-
336-
it("tracks max line length", async () => {
337-
await writeFile(join(tempDir, "varied.txt"), "short\na longer line here\nhi\n");
338-
const ix = await PathIndex.new(tempDir);
339-
const entry = ix.get("varied.txt") as IndexEntryFile;
340-
expect(entry.maxLineLen).toBe(Buffer.byteLength("a longer line here"));
327+
const entry = ix.get("three.txt") as IndexEntryFile;
328+
expect(entry.lineCount).toBe(0);
329+
expect(entry.maxLineLen).toBe(0);
330+
expect(entry.isBinary).toBe(false);
341331
});
342332

343-
it("detects binary files", async () => {
344-
await writeFile(join(tempDir, "binary.bin"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x0a, 0x1a]));
333+
it("detects binary by extension", async () => {
334+
await writeFile(join(tempDir, "image.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
335+
await writeFile(join(tempDir, "code.ts"), "const x = 1;\n");
345336
const ix = await PathIndex.new(tempDir);
346-
const entry = ix.get("binary.bin") as IndexEntryFile;
347-
expect(entry.isBinary).toBe(true);
348-
expect(entry.lineCount).toBe(0);
349-
expect(entry.maxLineLen).toBe(0);
337+
expect((ix.get("image.png") as IndexEntryFile).isBinary).toBe(true);
338+
expect((ix.get("code.ts") as IndexEntryFile).isBinary).toBe(false);
350339
});
351340

352341
it("handles empty files", async () => {
@@ -401,3 +390,41 @@ describe("PathIndex.new", () => {
401390
expect(root.children.map((c) => c.name).sort()).toEqual(["README.md", "lib", "main.go", "src"]);
402391
});
403392
});
393+
394+
describe("countFileLines", () => {
395+
let tempDir: string;
396+
397+
beforeEach(async () => {
398+
tempDir = await mkdtemp(join(tmpdir(), "countlines-"));
399+
});
400+
401+
afterEach(async () => {
402+
await rm(tempDir, { recursive: true, maxRetries: 3 });
403+
});
404+
405+
it("counts lines in text file", async () => {
406+
await writeFile(join(tempDir, "three.txt"), "one\ntwo\nthree\n");
407+
const result = await countFileLines(join(tempDir, "three.txt"));
408+
expect(result.lineCount).toBe(3);
409+
expect(result.isBinary).toBe(false);
410+
});
411+
412+
it("tracks max line length", async () => {
413+
await writeFile(join(tempDir, "varied.txt"), "short\na longer line here\nhi\n");
414+
const result = await countFileLines(join(tempDir, "varied.txt"));
415+
expect(result.maxLineLen).toBe(Buffer.byteLength("a longer line here"));
416+
});
417+
418+
it("detects binary content", async () => {
419+
await writeFile(join(tempDir, "bin"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x0a]));
420+
const result = await countFileLines(join(tempDir, "bin"));
421+
expect(result.isBinary).toBe(true);
422+
expect(result.lineCount).toBe(0);
423+
});
424+
425+
it("handles file without trailing newline", async () => {
426+
await writeFile(join(tempDir, "no-nl.txt"), "one\ntwo");
427+
const result = await countFileLines(join(tempDir, "no-nl.txt"));
428+
expect(result.lineCount).toBe(2);
429+
});
430+
});

claude/kensai/mcp/review/src/repofs/pathindex/pathindex.ts

Lines changed: 32 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,6 @@ export class PathIndex {
192192
readonly root: IndexEntryDir = { type: "dir", name: "", children: [] };
193193
readonly paths: string[] = [];
194194
readonly #entries = new Map<string, IndexEntry>();
195-
readonly #fdPool = new Pool(256);
196195

197196
private constructor(rootPath: string) {
198197
this.absRoot = nativePath.resolve(rootPath);
@@ -332,41 +331,21 @@ export class PathIndex {
332331
continue;
333332
}
334333

335-
const ext = path.extname(dirent.name).toLowerCase();
336-
337-
if (BINARY_EXTENSIONS.has(ext)) {
338-
work.push(
339-
fs.stat(absPath).then((s) => {
340-
const entry: IndexEntryFile = {
341-
type: "file",
342-
name: dirent.name,
343-
size: s.size,
344-
lineCount: 0,
345-
maxLineLen: 0,
346-
isBinary: true,
347-
};
348-
dirEntry.children.push(entry);
349-
this.paths.push(relPath);
350-
this.#entries.set(relPath, entry);
351-
}),
352-
);
353-
} else {
354-
work.push(
355-
Promise.all([fs.stat(absPath), this.#countLines(absPath)]).then(([s, lc]) => {
356-
const entry: IndexEntryFile = {
357-
type: "file",
358-
name: dirent.name,
359-
size: s.size,
360-
lineCount: lc.lineCount,
361-
maxLineLen: lc.maxLineLen,
362-
isBinary: lc.isBinary,
363-
};
364-
dirEntry.children.push(entry);
365-
this.paths.push(relPath);
366-
this.#entries.set(relPath, entry);
367-
}),
368-
);
369-
}
334+
work.push(
335+
fs.stat(absPath).then((s) => {
336+
const entry: IndexEntryFile = {
337+
type: "file",
338+
name: dirent.name,
339+
size: s.size,
340+
lineCount: 0,
341+
maxLineLen: 0,
342+
isBinary: BINARY_EXTENSIONS.has(path.extname(dirent.name).toLowerCase()),
343+
};
344+
dirEntry.children.push(entry);
345+
this.paths.push(relPath);
346+
this.#entries.set(relPath, entry);
347+
}),
348+
);
370349
}
371350

372351
await Promise.all(work);
@@ -395,57 +374,27 @@ export class PathIndex {
395374
return undefined;
396375
}
397376
}
398-
399-
/** Body-less line scan via {@link LineIter}. Binary files are detected by the null byte probe on the first chunk. */
400-
async #countLines(absPath: string): Promise<{ lineCount: number; maxLineLen: number; isBinary: boolean }> {
401-
await this.#fdPool.acquire();
402-
const stream = createReadStream(absPath);
403-
try {
404-
const iter = await LineIter.new(stream, { lineCap: 0 });
405-
let maxLineLen = 0;
406-
while (await iter.next()) {
407-
if (iter.len() > maxLineLen) maxLineLen = iter.len();
408-
}
409-
return { lineCount: iter.num(), maxLineLen, isBinary: false };
410-
} catch (err) {
411-
if (err instanceof ErrBinaryContent) {
412-
return { lineCount: 0, maxLineLen: 0, isBinary: true };
413-
}
414-
throw err;
415-
} finally {
416-
stream.destroy();
417-
this.#fdPool.release();
418-
}
419-
}
420377
}
421378

422-
/** Bounds concurrent async operations. {@link acquire} blocks when the limit is reached. */
423-
class Pool {
424-
readonly #limit: number;
425-
#active = 0;
426-
#queue: (() => void)[] = [];
427-
428-
constructor(limit: number) {
429-
this.#limit = limit;
430-
}
431-
432-
/** Take a slot. Returns a promise that resolves when a slot is available. */
433-
acquire(): Promise<void> | void {
434-
if (this.#active < this.#limit) {
435-
this.#active++;
436-
return;
379+
/** Count lines, detect binary, and measure max line length for a single file. */
380+
export async function countFileLines(
381+
absPath: string,
382+
): Promise<{ lineCount: number; maxLineLen: number; isBinary: boolean }> {
383+
const stream = createReadStream(absPath);
384+
try {
385+
const iter = await LineIter.new(stream, { lineCap: 0 });
386+
let maxLineLen = 0;
387+
while (await iter.next()) {
388+
if (iter.len() > maxLineLen) maxLineLen = iter.len();
437389
}
438-
return new Promise<void>((resolve) => this.#queue.push(resolve));
439-
}
440-
441-
/** Return a slot. Wakes the next waiter if any. */
442-
release(): void {
443-
const next = this.#queue.shift();
444-
if (next) {
445-
next();
446-
} else {
447-
this.#active--;
390+
return { lineCount: iter.num(), maxLineLen, isBinary: false };
391+
} catch (err) {
392+
if (err instanceof ErrBinaryContent) {
393+
return { lineCount: 0, maxLineLen: 0, isBinary: true };
448394
}
395+
throw err;
396+
} finally {
397+
stream.destroy();
449398
}
450399
}
451400

claude/kensai/mcp/review/src/repofs/repofs.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ export class RepoFs {
4444
}
4545

4646
const [index, git] = await Promise.all([PathIndex.new(absRoot, signal), Repo.open(absRoot, signal)]);
47-
4847
return new RepoFs(absRoot, index, git);
4948
}
5049

@@ -81,22 +80,26 @@ export class RepoFs {
8180
return this.#index.dir(this.resolve(p)) !== undefined;
8281
}
8382

84-
/** Resolves path, validates file entry in index, returns raw bytes. */
83+
/** Resolves path, validates via index or filesystem, returns raw bytes. Falls through to direct read for files not in the index. */
8584
async readFile(p: string): Promise<Buffer> {
8685
const rel = this.resolve(p);
8786
const entry = this.#index.get(rel);
8887

89-
if (!entry) {
90-
throw new RepoFsError(`file not found: ${rel}`);
88+
if (entry && entry.type !== "file") {
89+
throw new RepoFsError(`${rel}: not a regular file (${entry.type})`);
9190
}
9291

93-
if (entry.type !== "file") {
94-
throw new RepoFsError(`${rel}: not a regular file (${entry.type})`);
92+
const absPath = path.resolve(this.#root, rel);
93+
94+
if (!entry) {
95+
const stat = await fsp.stat(absPath).catch(() => null);
96+
if (!stat?.isFile()) {
97+
throw new RepoFsError(`file not found: ${rel}`);
98+
}
9599
}
96100

97101
this.#pendingReadPaths.push(rel);
98-
99-
return fsp.readFile(path.resolve(this.#root, rel));
102+
return fsp.readFile(absPath);
100103
}
101104

102105
/** Fuzzy search via {@link PathIndex.fuzzySearch}. */

claude/kensai/mcp/review/src/session/session.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createHash } from "node:crypto";
2+
import path from "node:path";
23

34
import type { GitFileStat, GitLogEntry } from "../repofs/git/git.ts";
5+
import { countFileLines } from "../repofs/pathindex/pathindex.ts";
46
import { RepoFs } from "../repofs/repofs.ts";
57
import { FindingsStorage } from "./findings-storage.ts";
68
import { GroundingStorage } from "./grounding-storage.ts";
@@ -82,11 +84,11 @@ export class Session {
8284
mode !== "uncommitted" ? rfs.git.log("HEAD", 1).then((entries) => entries[0] ?? null) : Promise.resolve(null),
8385
]);
8486

85-
const manifest = buildManifest(rfs, changedFiles);
8687
const reviewable = filterReviewableFiles(changedFiles);
8788
const changedPaths = new Set(changedFiles.map((f) => f.path));
8889

89-
const [diffs, instructions] = await Promise.all([
90+
const [manifest, diffs, instructions] = await Promise.all([
91+
buildManifest(rfs, changedFiles),
9092
fetchDiffs(rfs, base, head, reviewable),
9193
resolveInstructions(
9294
rfs,
@@ -179,26 +181,38 @@ export function filterReviewableFiles(files: readonly GitFileStat[]): GitFileSta
179181
return files.filter((f) => f.status !== "deleted" && !shouldSkipDiff(f.path));
180182
}
181183

182-
/** Builds per-file shape metrics from PathIndex for all non-deleted changed files. No I/O — index is pre-built. */
183-
function buildManifest(rfs: RepoFs, files: readonly GitFileStat[]): ManifestEntry[] {
184-
const entries: ManifestEntry[] = [];
184+
/** Builds per-file shape metrics for all non-deleted changed files. Counts lines on demand (only for changed files). */
185+
async function buildManifest(rfs: RepoFs, files: readonly GitFileStat[]): Promise<ManifestEntry[]> {
186+
const candidates: Array<{ path: string; entry: { size: number; isBinary: boolean } }> = [];
185187

186188
for (const f of files) {
187189
if (f.status === "deleted") continue;
188-
189190
const entry = rfs.index.get(f.path);
190191
if (!entry || entry.type !== "file") continue;
191-
192-
entries.push({
193-
path: f.path,
194-
bytes: entry.size,
195-
lines: entry.lineCount,
196-
maxLineLen: entry.maxLineLen,
197-
binary: entry.isBinary,
198-
});
192+
candidates.push({ path: f.path, entry });
199193
}
200194

201-
return entries;
195+
const results = await Promise.all(
196+
candidates.map(async ({ path: filePath, entry }) => {
197+
if (entry.isBinary) {
198+
return { path: filePath, bytes: entry.size, lines: 0, maxLineLen: 0, binary: true };
199+
}
200+
try {
201+
const lc = await countFileLines(path.resolve(rfs.root, filePath));
202+
return {
203+
path: filePath,
204+
bytes: entry.size,
205+
lines: lc.lineCount,
206+
maxLineLen: lc.maxLineLen,
207+
binary: lc.isBinary,
208+
};
209+
} catch {
210+
return { path: filePath, bytes: entry.size, lines: 0, maxLineLen: 0, binary: false };
211+
}
212+
}),
213+
);
214+
215+
return results;
202216
}
203217

204218
/**

0 commit comments

Comments
 (0)