UNPKG

pi-lens

Version:

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

513 lines (512 loc) • 18.7 kB
/** * Shared file path utilities for pi-lens */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { minimatch } from "./deps/minimatch.js"; import { getGlobalIgnorePatterns, getPiLensGlobalConfigPath, } from "./lens-config.js"; import { normalizeFilePath } from "./path-utils.js"; import { findPiLensProjectConfig, loadPiLensProjectConfig, } from "./project-lens-config.js"; import { safeSpawnAsync } from "./safe-spawn.js"; /** * Return the directory where pi-lens stores project-specific data * (caches, indexes, worklogs, etc.). * * Default: reuse <project>/.pi-lens if it already exists, otherwise use * ~/.pi-lens/projects/<project-slug> * * Override: set PILENS_DATA_DIR=/some/path — each project gets its own * subdirectory named after a sanitized form of its absolute path, e.g. * PILENS_DATA_DIR=~/.pi-lens/projects * → ~/.pi-lens/projects/home-user-myapp/ * * This keeps project folders clean and avoids creating .pi-lens folders * inside user projects. */ export function getProjectDataDir(cwd) { const legacyProjectDir = path.join(cwd, ".pi-lens"); const configuredBase = process.env.PILENS_DATA_DIR?.trim(); if (!configuredBase && fs.existsSync(legacyProjectDir)) { return legacyProjectDir; } const base = configuredBase || path.join(getGlobalPiLensDir(), "projects"); const normalized = normalizeFilePath(path.resolve(cwd)); const slug = normalized .replace(/^[a-z]:/i, "") // strip Windows drive letter .replace(/\/+/g, "-") // separators → dashes .replace(/[^A-Za-z0-9-]/g, "") // strip anything else .replace(/^-+/, "") // trim leading dashes .replace(/-+$/, ""); // trim trailing dashes return path.join(base.trim(), slug || "default"); } /** * Machine-global pi-lens directory: `~/.pi-lens/` by default. * * Used for logs (latency, cascade, read-guard, tree-sitter, actionable-warnings, * sessionstart), tool binaries (`~/.pi-lens/tools/`, `~/.pi-lens/bin/`), the * cross-process instance registry (`instances.json`, #449/#525), the * auto-install probe cache, and other state that is intentionally NOT * project-scoped — it spans every project pi-lens has touched. * * Override: set `PI_LENS_HOME=/some/path` to relocate this ENTIRE root (every * caller below routes through this one function, so one env var covers all of * them — see #525). Tests MUST set this to a per-worker temp dir in * `tests/support/vitest-setup.ts` rather than mocking each caller separately; * otherwise a test that exercises `registerInstance`/`sweepOrphans` or any * logger writes into the developer's REAL `~/.pi-lens` (dogfooded live: a * test-fixture instance survived in the real `instances.json` for 17h). * * Distinct from `getProjectDataDir(cwd)`, which respects `PILENS_DATA_DIR` * (project-scoped) and produces per-project subdirectories. Callers writing * project caches, snapshots, or worklogs should use `getProjectDataDir(cwd)` * instead — `PI_LENS_HOME` is the MACHINE-scoped sibling of that override. */ export function getGlobalPiLensDir() { const override = process.env.PI_LENS_HOME?.trim(); if (override) return path.resolve(override); return path.join(os.homedir(), ".pi-lens"); } /** * Directories to exclude from all scans (build outputs, dependencies, caches). * Used consistently across all scanners to avoid noise from generated files. */ export const EXCLUDED_DIRS = [ "node_modules", ".git", "dist", "build", ".turbo", ".cache", "target", "out", ".parcel-cache", ".svelte-kit", ".nuxt", ".yarn", ".pnpm-store", ".gradle", ".next", ".pi-lens", ".pi", // pi agent directory ".ruff_cache", // Python linter cache ".worktrees", ".claude", ".codex", ".rescue", ".agents", ".gstack", ".superpowers", ".guardrails", ".playwright-cli", ".playwright-mcp", ".vscode", "venv", ".venv", "coverage", "__pycache__", ".tox", ".pytest_cache", "*.dSYM", // Vendored upstream source conventions — universally too large to scan "vendor", // Go modules, PHP Composer, Ruby Bundler "third_party", // Chromium/Google convention (llama.cpp, sherpa-onnx, gRPC, TF) "third-party", "vendors", ]; function resolveGitIgnoreRoot(startDir) { const fallback = path.resolve(startDir); let current = fallback; while (true) { if (fs.existsSync(path.join(current, ".git"))) return current; const parent = path.dirname(current); if (parent === current) return fallback; current = parent; } } function collapseSlashes(value) { let out = ""; let previousWasSlash = false; for (const ch of value) { if (ch === "/") { if (!previousWasSlash) out += ch; previousWasSlash = true; continue; } out += ch === "\\" ? "/" : ch; previousWasSlash = false; } return out; } function stripLeadingDotSlash(value) { return value.startsWith("./") ? value.slice(2) : value; } function stripTrailingSlashes(value) { let end = value.length; while (end > 0 && value[end - 1] === "/") end -= 1; return value.slice(0, end); } function stripLeadingSlashes(value) { let start = 0; while (start < value.length && value[start] === "/") start += 1; return value.slice(start); } function normalizeIgnorePath(value) { return collapseSlashes(stripLeadingDotSlash(value)); } function stripTrailingSpaces(value) { // Good-enough gitignore whitespace handling: unescaped trailing spaces are ignored. let end = value.length; while (end > 0 && value[end - 1] === " " && value[end - 2] !== "\\") end -= 1; return value.slice(0, end).replace(/\\ /g, " "); } function parseGitignoreContent(content) { const patterns = []; for (const rawLine of content.split(/\r?\n/)) { let line = stripTrailingSpaces(rawLine.trimStart()); if (!line || line.startsWith("#")) continue; let negated = false; if (line.startsWith("!")) { negated = true; line = line.slice(1); } line = normalizeIgnorePath(line); if (!line) continue; const directoryOnly = line.endsWith("/"); if (directoryOnly) line = stripTrailingSlashes(line); const rooted = line.startsWith("/"); if (rooted) line = stripLeadingSlashes(line); if (!line) continue; patterns.push({ pattern: line, negated, directoryOnly, rooted, hasSlash: line.includes("/"), }); } return patterns; } function expandGitignorePattern(pattern) { const body = pattern.pattern; if (pattern.directoryOnly) { if (pattern.rooted || pattern.hasSlash) return [body, `${body}/**`]; return [body, `${body}/**`, `**/${body}`, `**/${body}/**`]; } if (pattern.rooted || pattern.hasSlash) return [body]; return [body, `**/${body}`]; } function matchesGitignorePattern(pattern, relativePath, isDirectory) { const candidate = stripLeadingSlashes(normalizeIgnorePath(relativePath)); if (!candidate) return false; const candidates = isDirectory ? [candidate, `${candidate}/`] : [candidate]; const options = { dot: true, nocase: process.platform === "win32" }; return expandGitignorePattern(pattern).some((expanded) => { if (isDirectory && expanded.endsWith("/**")) { const prefix = expanded.slice(0, -3); if (candidate === prefix || candidate.startsWith(`${prefix}/`)) return true; } return candidates.some((value) => minimatch(value, expanded, options)); }); } export function readGitignorePatterns(rootDir) { const gitignorePath = path.join(rootDir, ".gitignore"); try { return parseGitignoreContent(fs.readFileSync(gitignorePath, "utf-8")); } catch { return []; } } function ancestorDirsBetween(rootDir, targetDir) { const relative = path.relative(rootDir, targetDir); if (relative.startsWith("..") || path.isAbsolute(relative)) return []; const dirs = [rootDir]; if (!relative) return dirs; let current = rootDir; for (const segment of relative.split(path.sep).filter(Boolean)) { current = path.join(current, segment); dirs.push(current); } return dirs; } function buildProjectIgnoreMatcher(resolvedRoot, patterns) { const nestedCache = new Map(); const patternsForDir = (dir) => { if (dir === resolvedRoot) return patterns; const gitignoreMtime = gitignoreMtimeMs(dir); const cached = nestedCache.get(dir); if (cached?.gitignoreMtimeMs === gitignoreMtime) return cached.patterns; const nextPatterns = readGitignorePatterns(dir); nestedCache.set(dir, { gitignoreMtimeMs: gitignoreMtime, patterns: nextPatterns, }); return nextPatterns; }; // Per-matcher path → boolean memo. The matcher itself is cached by // `getProjectIgnoreMatcher` keyed on `.gitignore` mtime, so this Map's // lifetime is bounded to a single set of ignore rules — when any // `.gitignore` changes, the matcher is rebuilt and the memo is dropped // with it. Without this memo, every background scan (comment scan, knip, // jscpd, call-graph, source-filter, pipeline) recomputes O(ancestorDirs × // patterns) per file, multiplying into 2-3s of pure CPU on a 2k-file // project. With it, the second visitor of the same path is O(1). const isIgnoredMemo = new Map(); return { rootDir: resolvedRoot, patterns, isIgnored(filePath, isDirectory = false) { const resolved = path.resolve(filePath); // Two namespaces (D: for directory queries, F: for file queries) // because gitignore semantics differ for trailing-slash patterns. const memoKey = (isDirectory ? "D:" : "F:") + resolved; const cached = isIgnoredMemo.get(memoKey); if (cached !== undefined) return cached; const rootRelative = path.relative(resolvedRoot, resolved); if (!rootRelative || rootRelative.startsWith("..") || path.isAbsolute(rootRelative)) { isIgnoredMemo.set(memoKey, false); return false; } let ignored = false; const patternDirs = ancestorDirsBetween(resolvedRoot, path.dirname(resolved)); for (const dir of patternDirs) { const dirPatterns = patternsForDir(dir); if (dirPatterns.length === 0) continue; const relative = path.relative(dir, resolved); const normalized = normalizeIgnorePath(relative); for (const pattern of dirPatterns) { if (!matchesGitignorePattern(pattern, normalized, isDirectory)) continue; ignored = !pattern.negated; } } isIgnoredMemo.set(memoKey, ignored); return ignored; }, }; } export function createProjectIgnoreMatcher(rootDir, extraPatterns = [], globalPatterns = []) { const resolvedRoot = resolveGitIgnoreRoot(rootDir); // Precedence is gitignore order: LATER patterns override earlier ones. So // global (lowest) → project .gitignore → project .pi-lens.json (highest), // which lets a project `!negation` re-include a globally-ignored path (#252). const patterns = [ ...parseGitignoreContent(globalPatterns.join("\n")), ...readGitignorePatterns(resolvedRoot), ...parseGitignoreContent(extraPatterns.join("\n")), ]; return buildProjectIgnoreMatcher(resolvedRoot, patterns); } const projectIgnoreMatcherCache = new Map(); /** * mtime of the global `~/.pi-lens/config.json` (or the PI_LENS_CONFIG_PATH * override). Part of the ignore-matcher cache key so editing global ignore * patterns takes effect without a restart (#252). -1 when absent. */ function globalConfigMtimeMs() { try { return fs.statSync(getPiLensGlobalConfigPath()).mtimeMs; } catch { return -1; } } function gitignoreMtimeMs(rootDir) { try { return fs.statSync(path.join(rootDir, ".gitignore")).mtimeMs; } catch { return -1; } } /** * The project config file found by the same upward walk as the loader. Cache * invalidation must track the actual file found, not only a file directly under * the git root: nested worktrees/submodules can legitimately inherit a * `.pi-lens.json` from a parent directory. */ function lensConfigInfo(rootDir) { const info = findPiLensProjectConfig(rootDir); return info ? { info, path: info.path, mtimeMs: info.mtimeMs } : { info, path: undefined, mtimeMs: -1 }; } export function getProjectIgnoreMatcher(rootDir) { const resolvedRoot = resolveGitIgnoreRoot(rootDir); const gitignoreMtime = gitignoreMtimeMs(resolvedRoot); const lensConfig = lensConfigInfo(resolvedRoot); const globalMtime = globalConfigMtimeMs(); const cached = projectIgnoreMatcherCache.get(resolvedRoot); if (cached?.gitignoreMtimeMs === gitignoreMtime && cached?.lensConfigPath === lensConfig.path && cached?.lensConfigMtimeMs === lensConfig.mtimeMs && cached?.globalConfigMtimeMs === globalMtime) { return cached.matcher; } // Load both configs fresh on cache miss. On a cache HIT (the common case) // none of this runs — the only per-call cost is the mtime stats above. The // project loader is itself mtime-cached; the global loader re-parses, but // only here on miss (when some tracked mtime changed). const projectConfig = loadPiLensProjectConfig(resolvedRoot, lensConfig.info); const matcher = createProjectIgnoreMatcher(resolvedRoot, projectConfig.ignore, getGlobalIgnorePatterns()); projectIgnoreMatcherCache.set(resolvedRoot, { gitignoreMtimeMs: gitignoreMtime, lensConfigPath: lensConfig.path, lensConfigMtimeMs: lensConfig.mtimeMs, globalConfigMtimeMs: globalMtime, matcher, }); return matcher; } export function isPathIgnoredByProject(filePath, rootDir, isDirectory = false) { return getProjectIgnoreMatcher(rootDir).isIgnored(filePath, isDirectory); } export function getProjectIgnoreGlobs(rootDir) { return readGitignorePatterns(rootDir) .filter((pattern) => !pattern.negated) .flatMap((pattern) => expandGitignorePattern(pattern)); } /** * Read simple directory-name entries from a root .gitignore. * * Prefer createProjectIgnoreMatcher() for path-aware gitignore matching. This * helper is kept for callers/tests that only need simple directory names. */ export function readGitignoreDirs(rootDir) { return readGitignorePatterns(rootDir) .filter((entry) => !entry.negated && !entry.pattern.includes("*") && !entry.pattern.includes("?") && !entry.pattern.includes("[") && !entry.pattern.includes("/")) .map((entry) => entry.pattern); } function globToRegExp(glob) { const escaped = glob .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); return new RegExp(`^${escaped}$`, "i"); } /** * Match directory name against exclusion patterns. * Supports exact names and lightweight glob patterns (for example `*.dSYM`). */ export function isExcludedDirName(dirName, extraPatterns = []) { const candidate = dirName.trim(); if (!candidate) return false; const patterns = [...EXCLUDED_DIRS, ...extraPatterns] .map((p) => p.trim()) .filter((p) => p.length > 0); const candidateLower = candidate.toLowerCase(); for (const pattern of patterns) { const patLower = pattern.toLowerCase(); if (!patLower.includes("*") && !patLower.includes("?")) { if (candidateLower === patLower) return true; continue; } if (globToRegExp(pattern).test(candidate)) return true; } return false; } /** * Convert excluded directory names into glob patterns used by scanners. */ export function getExcludedDirGlobs() { return EXCLUDED_DIRS.map((dir) => `**/${dir}/**`); } /** * Shared Knip ignore patterns derived from central exclusions. */ export function getKnipIgnorePatterns() { return [ ...getExcludedDirGlobs(), "**/*.test.ts", "**/*.test.tsx", "**/*.test.js", "**/*.test.jsx", "**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js", "**/*.spec.jsx", "**/*.poc.test.ts", "**/*.poc.test.tsx", "**/__tests__/**", "**/tests/**", ]; } /** * Spawn a command and detect whether it modified a file on disk. * Returns 1 if the file content changed after the command ran, 0 otherwise. * Useful for auto-fix tools (ESLint, Stylelint, RuboCop, etc.). */ export async function detectFileChangedAfterCommand(filePath, command, args, cwd, ignoreStatuses = []) { let before = ""; try { before = fs.readFileSync(filePath, "utf-8"); } catch { return 0; } const result = await safeSpawnAsync(command, args, { timeout: 30000, cwd, }); if (result.error) return 0; if (result.status !== 0 && !ignoreStatuses.includes(result.status ?? -1)) { return 0; } try { const after = fs.readFileSync(filePath, "utf-8"); return before !== after ? 1 : 0; } catch { return 0; } } /** * Check if file path is a test/fixture/mock file. * Used by secrets scanner, rate command, and dispatch runners * to skip these files (false positives on fake credentials, etc). */ export function isTestFile(filePath) { const normalized = filePath.replace(/\\/g, "/"); return (normalized.includes(".test.") || normalized.includes(".spec.") || normalized.includes("/test/") || normalized.includes("/tests/") || normalized.includes("__tests__/") || normalized.includes("test-utils") || normalized.startsWith("test-") || normalized.includes(".fixture.") || normalized.includes(".mock.")); }