beadboard/src/lib/scanner.ts

275 lines
6.7 KiB
TypeScript

import fs from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
import { listProjects } from './registry';
export type ScanMode = 'default' | 'full-drive';
export interface ScannerProject {
root: string;
key: string;
displayPath: string;
}
export interface ScanStats {
scannedDirectories: number;
ignoredDirectories: number;
skippedDirectories: number;
elapsedMs: number;
}
export interface ScanOptions {
mode?: ScanMode;
maxDepth?: number;
roots?: string[];
ignoreDirectories?: string[];
}
export interface ScanResult {
mode: ScanMode;
roots: string[];
projects: ScannerProject[];
stats: ScanStats;
}
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_IGNORE_DIRECTORIES = [
'node_modules',
'.git',
'.next',
'dist',
'build',
'out',
'coverage',
'artifacts',
'logs',
'.worktrees', // TODO: confirm whether worktrees should be scan targets.
'worktrees',
'.agents',
'.kimi',
'.zenflow',
'.gemini',
'appdata',
];
const DEFAULT_IGNORE_PATH_FRAGMENTS = [
'\\go\\pkg\\mod\\',
'\\.agents\\skills\\',
'\\.kimi\\skills\\',
'\\.gemini\\skills\\',
'\\.zenflow\\worktrees\\',
];
function userProfileRoot(): string {
return process.env.USERPROFILE?.trim() || os.homedir();
}
function toCanonicalRoot(input: string): string {
return canonicalizeWindowsPath(input);
}
function shouldSkipFsError(error: NodeJS.ErrnoException): boolean {
return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM';
}
async function ensureDirectoryExists(input: string): Promise<string | null> {
try {
const stat = await fs.stat(input);
return stat.isDirectory() ? input : null;
} catch (error) {
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
return null;
}
throw error;
}
}
async function fileExists(input: string): Promise<boolean> {
try {
const stat = await fs.stat(input);
return stat.isFile();
} catch (error) {
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
return false;
}
throw error;
}
}
async function resolveFullDriveRoots(): Promise<string[]> {
const candidates = ['C:\\', 'D:\\'];
const roots: string[] = [];
for (const candidate of candidates) {
const existing = await ensureDirectoryExists(candidate);
if (existing) {
roots.push(existing);
}
}
return roots;
}
export async function resolveScanRoots(options: ScanOptions = {}): Promise<string[]> {
const mode: ScanMode = options.mode ?? 'default';
const registryProjects = await listProjects();
const roots = [
userProfileRoot(),
...registryProjects.map((project) => project.path),
...(options.roots ?? []),
];
if (mode === 'full-drive') {
roots.push(...(await resolveFullDriveRoots()));
}
const seen = new Set<string>();
const normalizedRoots: string[] = [];
for (const root of roots) {
const normalized = toCanonicalRoot(root);
const key = windowsPathKey(normalized);
if (seen.has(key)) {
continue;
}
const existing = await ensureDirectoryExists(normalized);
if (!existing) {
continue;
}
seen.add(key);
normalizedRoots.push(existing);
}
return normalizedRoots;
}
function buildIgnoreSet(additional: string[] = []): Set<string> {
return new Set(
[...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean),
);
}
function shouldIgnorePath(dir: string): boolean {
const normalized = toCanonicalRoot(dir).toLowerCase();
return DEFAULT_IGNORE_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment));
}
function shouldIgnoreDirectoryName(name: string): boolean {
const normalized = name.trim().toLowerCase();
return (
normalized.startsWith('beadboard-read-') ||
normalized.startsWith('beadboard-watch-') ||
normalized.startsWith('skills-')
);
}
function recordProject(projects: Map<string, ScannerProject>, root: string): void {
const normalized = toCanonicalRoot(root);
const key = windowsPathKey(normalized);
if (!projects.has(key)) {
projects.set(key, {
root: normalized,
key,
displayPath: toDisplayPath(normalized),
});
}
}
async function scanRoot(
root: string,
maxDepth: number,
ignoreSet: Set<string>,
projects: Map<string, ScannerProject>,
stats: ScanStats,
): Promise<void> {
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
if (current.depth > 0 && shouldIgnorePath(current.dir)) {
stats.ignoredDirectories += 1;
continue;
}
stats.scannedDirectories += 1;
let entries: Dirent[];
try {
entries = await fs.readdir(current.dir, { withFileTypes: true });
} catch (error) {
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
stats.skippedDirectories += 1;
continue;
}
throw error;
}
let hasBeads = false;
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (entry.name === '.beads') {
hasBeads = true;
continue;
}
const entryName = entry.name.toLowerCase();
if (ignoreSet.has(entryName) || shouldIgnoreDirectoryName(entryName)) {
stats.ignoredDirectories += 1;
continue;
}
if (current.depth < maxDepth) {
queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
}
}
if (hasBeads) {
const issuesPath = path.join(current.dir, '.beads', 'issues.jsonl');
const fallbackIssuesPath = path.join(current.dir, '.beads', 'issues.jsonl.new');
const [primaryExists, fallbackExists] = await Promise.all([fileExists(issuesPath), fileExists(fallbackIssuesPath)]);
if (primaryExists || fallbackExists) {
recordProject(projects, current.dir);
}
}
}
}
export async function scanForProjects(options: ScanOptions = {}): Promise<ScanResult> {
const mode: ScanMode = options.mode ?? 'default';
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const ignoreSet = buildIgnoreSet(options.ignoreDirectories);
const roots = await resolveScanRoots(options);
const projects = new Map<string, ScannerProject>();
const stats: ScanStats = {
scannedDirectories: 0,
ignoredDirectories: 0,
skippedDirectories: 0,
elapsedMs: 0,
};
const start = Date.now();
for (const root of roots) {
await scanRoot(root, maxDepth, ignoreSet, projects, stats);
}
stats.elapsedMs = Date.now() - start;
return {
mode,
roots,
projects: Array.from(projects.values()),
stats,
};
}