src / workspace / walk.ts
src / workspace / walk.ts
import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path";
import type { WorkspaceBoundary } from "./boundary";
export interface WalkOptions {
maxDepth: number;
maxEntries: number;
includeHidden?: boolean;
ignoreNames?: string[];
}
export interface WalkEntry {
path: string;
type: "file" | "directory" | "symlink" | "other";
bytes?: number;
depth: number;
}
const DEFAULT_IGNORES = new Set(["node_modules", ".git", ".agentic"]);
export async function walkWorkspace(
boundary: WorkspaceBoundary,
startPath: string,
options: WalkOptions,
): Promise<{ entries: WalkEntry[]; truncated: boolean }> {
const entries: WalkEntry[] = [];
const ignores = new Set([...DEFAULT_IGNORES, ...(options.ignoreNames ?? [])]);
let truncated = false;
const inspect = async (relativePath: string) => {
const absolute = await boundary.resolveRead(relativePath);
return { absolute, info: await lstat(absolute) };
};
const visit = async (relativeDirectory: string, depth: number): Promise<void> => {
if (depth > options.maxDepth || truncated) return;
// Resolve on every recursion. A directory replaced with a symlink between
// parent enumeration and traversal is rejected by the workspace boundary.
const { absolute, info } = await inspect(relativeDirectory);
if (!info.isDirectory() || info.isSymbolicLink()) return;
const children = await readdir(absolute, { withFileTypes: true });
children.sort((a, b) => a.name.localeCompare(b.name));
for (const child of children) {
if (entries.length >= options.maxEntries) {
truncated = true;
return;
}
if (!options.includeHidden && child.name.startsWith(".")) continue;
if (ignores.has(child.name)) continue;
const childAbsolute = join(absolute, child.name);
const current = await lstat(childAbsolute);
const rel = boundary.relativePath(childAbsolute);
if (current.isSymbolicLink()) {
entries.push({ path: rel, type: "symlink", depth });
} else if (current.isDirectory()) {
entries.push({ path: rel, type: "directory", depth });
await visit(rel, depth + 1);
} else if (current.isFile()) {
entries.push({ path: rel, type: "file", bytes: current.size, depth });
} else {
entries.push({ path: rel, type: "other", depth });
}
}
};
const start = await inspect(startPath);
const startRelative = boundary.relativePath(start.absolute);
if (start.info.isSymbolicLink()) {
entries.push({ path: startRelative, type: "symlink", depth: 0 });
} else if (start.info.isDirectory()) {
await visit(startRelative, 0);
} else {
entries.push({
path: startRelative,
type: start.info.isFile() ? "file" : "other",
...(start.info.isFile() ? { bytes: start.info.size } : {}),
depth: 0,
});
}
return { entries, truncated };
}
import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path";
import type { WorkspaceBoundary } from "./boundary";
export interface WalkOptions {
maxDepth: number;
maxEntries: number;
includeHidden?: boolean;
ignoreNames?: string[];
}
export interface WalkEntry {
path: string;
type: "file" | "directory" | "symlink" | "other";
bytes?: number;
depth: number;
}
const DEFAULT_IGNORES = new Set(["node_modules", ".git", ".agentic"]);
export async function walkWorkspace(
boundary: WorkspaceBoundary,
startPath: string,
options: WalkOptions,
): Promise<{ entries: WalkEntry[]; truncated: boolean }> {
const entries: WalkEntry[] = [];
const ignores = new Set([...DEFAULT_IGNORES, ...(options.ignoreNames ?? [])]);
let truncated = false;
const inspect = async (relativePath: string) => {
const absolute = await boundary.resolveRead(relativePath);
return { absolute, info: await lstat(absolute) };
};
const visit = async (relativeDirectory: string, depth: number): Promise<void> => {
if (depth > options.maxDepth || truncated) return;
// Resolve on every recursion. A directory replaced with a symlink between
// parent enumeration and traversal is rejected by the workspace boundary.
const { absolute, info } = await inspect(relativeDirectory);
if (!info.isDirectory() || info.isSymbolicLink()) return;
const children = await readdir(absolute, { withFileTypes: true });
children.sort((a, b) => a.name.localeCompare(b.name));
for (const child of children) {
if (entries.length >= options.maxEntries) {
truncated = true;
return;
}
if (!options.includeHidden && child.name.startsWith(".")) continue;
if (ignores.has(child.name)) continue;
const childAbsolute = join(absolute, child.name);
const current = await lstat(childAbsolute);
const rel = boundary.relativePath(childAbsolute);
if (current.isSymbolicLink()) {
entries.push({ path: rel, type: "symlink", depth });
} else if (current.isDirectory()) {
entries.push({ path: rel, type: "directory", depth });
await visit(rel, depth + 1);
} else if (current.isFile()) {
entries.push({ path: rel, type: "file", bytes: current.size, depth });
} else {
entries.push({ path: rel, type: "other", depth });
}
}
};
const start = await inspect(startPath);
const startRelative = boundary.relativePath(start.absolute);
if (start.info.isSymbolicLink()) {
entries.push({ path: startRelative, type: "symlink", depth: 0 });
} else if (start.info.isDirectory()) {
await visit(startRelative, 0);
} else {
entries.push({
path: startRelative,
type: start.info.isFile() ? "file" : "other",
...(start.info.isFile() ? { bytes: start.info.size } : {}),
depth: 0,
});
}
return { entries, truncated };
}