UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

260 lines (259 loc) • 10.2 kB
/** * Startup scan safety — gates eager cache warmups to real project roots. * * Prevents pi-lens from scanning $HOME or generic directories at session * start, which would hang or produce meaningless results. * * Credit: alexx-ftw (PR #1) */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { getProjectIgnoreMatcher, } from "./file-utils.js"; import { isAtOrAboveHomeDir } from "./path-utils.js"; import { readDirEntriesSafe, shouldRecurseIntoDir } from "./source-walker.js"; export const PROJECT_ROOT_MARKERS = [ ".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "composer.json", ]; export const MAX_STARTUP_SOURCE_FILES = 2000; const SOURCE_FILE_PATTERN = /\.(ts|tsx|js|jsx|py|go|rs|rb)$/; export function findNearestProjectRoot(startDir) { let current = path.resolve(startDir); while (true) { if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(current, marker)))) { return current; } const parent = path.dirname(current); if (parent === current) return null; current = parent; } } /** Shared (rootDir, ignoreMatcher, stack) setup for both count-walk variants below. */ function initSourceCountWalk(dir) { const rootDir = path.resolve(dir); const ignoreMatcher = getProjectIgnoreMatcher(rootDir); return { rootDir, ignoreMatcher, stack: [rootDir] }; } /** * Shared per-entry decision for both `countSourceFilesWithinLimit` and its * async twin: pushes a recursable directory onto `stack` (mutated in place) * and reports whether `entry` itself counts as a source file. Extracted * (refs #191) so the sync/async loops don't carry a byte-identical * directory-branch block — the two loops still own their own traversal * shape (sync recursion-via-stack vs. async with a yield cadence), only this * per-entry classification is shared. * * Directories here never check for symlinks or generated-artifact names — * always follows symlinks (unlike source-filter.ts's collectSourceFiles*). */ function classifyCountEntry(entry, fullPath, ignoreMatcher, stack) { if (entry.isDirectory()) { if (shouldRecurseIntoDir(entry, fullPath, { ignoreMatcher, followSymlinks: true })) { stack.push(fullPath); } return false; } return (entry.isFile() && !ignoreMatcher.isIgnored(fullPath, false) && SOURCE_FILE_PATTERN.test(entry.name)); } export function countSourceFilesWithinLimit(dir, limit) { let count = 0; const { ignoreMatcher, stack } = initSourceCountWalk(dir); while (stack.length > 0) { const current = stack.pop(); if (!current) continue; const entries = readDirEntriesSafe(current); for (const entry of entries) { const fullPath = path.join(current, entry.name); if (classifyCountEntry(entry, fullPath, ignoreMatcher, stack)) { count += 1; if (count > limit) return count; } } } return count; } // Process-lifetime memo for the (cwd, homeDir, maxSourceFiles) tuple. The // underlying computation walks the entire project root counting source // files and is dominated by ignoreMatcher.isIgnored() calls; on a 2k-file // project it costs ~2-3s the first time. Every `session_start` invocation // (boot, /new, --print) recomputes this otherwise. Since the answer // depends only on the file tree shape and ignore rules — both of which // are also captured by the project snapshot freshness check upstream — // in-process memoisation is safe for the duration of a single pi process. const startupScanContextCache = new Map(); export function resolveStartupScanContext(cwd, options = {}) { const cacheKey = path.resolve(cwd) + "|" + (options.homeDir ?? "") + "|" + (options.maxSourceFiles ?? ""); const cached = startupScanContextCache.get(cacheKey); if (cached) return cached; const result = computeStartupScanContext(cwd, options); startupScanContextCache.set(cacheKey, result); return result; } function computeStartupScanContext(cwd, options = {}) { const resolvedCwd = path.resolve(cwd); const homeDir = path.resolve(options.homeDir ?? os.homedir()); const maxSourceFiles = options.maxSourceFiles ?? MAX_STARTUP_SOURCE_FILES; const projectRoot = findNearestProjectRoot(resolvedCwd); if (!projectRoot) { return { cwd: resolvedCwd, scanRoot: resolvedCwd, projectRoot: null, canWarmCaches: false, reason: isAtOrAboveHomeDir(resolvedCwd, homeDir) ? "home-dir" : "no-project-root", }; } // A marker resolved at $HOME — OR at an ancestor of it (e.g. /home, // C:\Users) — means the upward search escaped the workspace; warming caches // would walk an unrelated tree (#250/#253). The old exact `=== homeDir` // check missed the above-home case. if (isAtOrAboveHomeDir(projectRoot, homeDir)) { return { cwd: resolvedCwd, scanRoot: projectRoot, projectRoot, canWarmCaches: false, reason: "home-dir", }; } const sourceFileCount = countSourceFilesWithinLimit(projectRoot, maxSourceFiles); if (sourceFileCount > maxSourceFiles) { return { cwd: resolvedCwd, scanRoot: projectRoot, projectRoot, canWarmCaches: false, reason: "too-many-source-files", sourceFileCount, }; } return { cwd: resolvedCwd, scanRoot: projectRoot, projectRoot, canWarmCaches: true, sourceFileCount, }; } // --------------------------------------------------------------------------- // Async, chunked-yield counterparts used by the cold-start warmup pipeline // (see runtime-session.ts handleSessionStart). The synchronous variants above // block the Node event loop for the full duration of the project walk, which // is fine when called from a non-interactive code path but freezes the TUI // when called during `session_start`. These async variants do the same work // but yield control via `await new Promise(setImmediate)` every N directory // entries, so stdin handlers (i.e. keystrokes) stay responsive. // // They share the same memo (`startupScanContextCache`) as their sync siblings, // so whichever runs first warms the cache for the other. By design the // warmup pipeline runs the async version 2s after a cold-start "quick" return, // then the user's first /new sees a sync-path cache hit and skips the work // entirely. // --------------------------------------------------------------------------- export async function countSourceFilesWithinLimitAsync(dir, limit, opts = {}) { // Yield every 100 entries by default. Empirically each yield costs ~0.1ms // of overhead and a 2k-file project produces ~20 yields, so the total // async overhead is well under 5ms while keeping per-burst sync work // under 50ms (the perceptual threshold for "instant" keystrokes). const yieldEvery = opts.yieldEvery ?? 100; let count = 0; let processedSinceYield = 0; const { ignoreMatcher, stack } = initSourceCountWalk(dir); while (stack.length > 0) { const current = stack.pop(); if (!current) continue; const entries = readDirEntriesSafe(current); for (const entry of entries) { const fullPath = path.join(current, entry.name); if (classifyCountEntry(entry, fullPath, ignoreMatcher, stack)) { count += 1; if (count > limit) return count; } if (++processedSinceYield % yieldEvery === 0) { // Yield to the macrotask queue. setImmediate (not Promise.resolve) // is required: stdin "data" events are macrotasks too, and a // microtask-only yield would not unblock keystroke handling. await new Promise((resolve) => setImmediate(resolve)); } } } return count; } export async function resolveStartupScanContextAsync(cwd, options = {}) { const cacheKey = path.resolve(cwd) + "|" + (options.homeDir ?? "") + "|" + (options.maxSourceFiles ?? ""); const cached = startupScanContextCache.get(cacheKey); if (cached) return cached; const resolvedCwd = path.resolve(cwd); const homeDir = path.resolve(options.homeDir ?? os.homedir()); const maxSourceFiles = options.maxSourceFiles ?? MAX_STARTUP_SOURCE_FILES; const projectRoot = findNearestProjectRoot(resolvedCwd); let result; if (!projectRoot) { result = { cwd: resolvedCwd, scanRoot: resolvedCwd, projectRoot: null, canWarmCaches: false, reason: isAtOrAboveHomeDir(resolvedCwd, homeDir) ? "home-dir" : "no-project-root", }; } else if (isAtOrAboveHomeDir(projectRoot, homeDir)) { result = { cwd: resolvedCwd, scanRoot: projectRoot, projectRoot, canWarmCaches: false, reason: "home-dir", }; } else { const sourceFileCount = await countSourceFilesWithinLimitAsync(projectRoot, maxSourceFiles); if (sourceFileCount > maxSourceFiles) { result = { cwd: resolvedCwd, scanRoot: projectRoot, projectRoot, canWarmCaches: false, reason: "too-many-source-files", sourceFileCount, }; } else { result = { cwd: resolvedCwd, scanRoot: projectRoot, projectRoot, canWarmCaches: true, sourceFileCount, }; } } startupScanContextCache.set(cacheKey, result); return result; }