pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
321 lines (320 loc) • 12.6 kB
JavaScript
/**
* Source File Filter — Deduplicates source files by detecting build artifacts.
*
* Problem: When scanning a codebase, we encounter both source files and their
* compiled/transpiled outputs (TypeScript → JavaScript, Vue → JavaScript, etc.).
* Scanning both wastes time and produces duplicate findings.
*
* Solution: For each file, check if a "higher precedence" source sibling exists.
* If yes, skip the file as a build artifact. If no, keep it as hand-written source.
*
* Supported ecosystems:
* - TypeScript: .ts shadows .js, .tsx shadows .jsx
* - Vue/Svelte: .vue/.svelte shadows .js
* - CoffeeScript: .coffee shadows .js
*
* Files without higher-precedence siblings are kept only when they do not look
* generated/codegen-produced (hand-written JS, Python, Go, Rust, etc.).
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getProjectIgnoreMatcher } from "./file-utils.js";
import { isDeclarationFile, isGeneratedOrArtifact, } from "./generated-artifacts.js";
import { normalizeEphemeralMapKey } from "./path-utils.js";
import { isSlowFs, SLOW_FS_REDUCED_MAX_FILES } from "./slow-fs.js";
import { readDirEntriesSafe, shouldRecurseIntoDir } from "./source-walker.js";
/**
* Create a fresh, empty per-walk probe cache. Callers that enumerate many
* files (a single `collectSourceFiles`/`collectSourceFilesAsync` invocation)
* should create one of these at the start of the walk and pass it through;
* it must not be reused across separate walks.
*/
export function createArtifactProbeCache() {
return new Map();
}
function probeExists(filePath, cache) {
if (!cache)
return fs.existsSync(filePath);
const key = normalizeEphemeralMapKey(filePath);
const cached = cache.get(key);
if (cached !== undefined)
return cached;
const result = fs.existsSync(filePath);
cache.set(key, result);
return result;
}
/**
* Mapping of file extension to the extensions it shadows (build artifacts).
* Order matters: first entry has highest precedence.
*/
export const SOURCE_PRECEDENCE = {
".ts": [".js", ".mjs", ".cjs"],
".tsx": [".jsx", ".js", ".mjs", ".cjs"],
".vue": [".js", ".mjs"],
".svelte": [".js", ".mjs"],
".coffee": [".js"],
};
/**
* All extensions that could be source or artifacts, in precedence order.
*/
export const ALL_SCANNABLE_EXTENSIONS = [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".vue",
".svelte",
".coffee",
".py",
".go",
".rs",
".rb",
".rake",
".gemspec",
".ru",
];
function shouldSkipGeneratedOrArtifact(filePath, options) {
const includeDeclarations = options?.includeDeclarationFiles === true;
if (options?.includeGenerated === true) {
return !includeDeclarations && isDeclarationFile(filePath);
}
return isGeneratedOrArtifact(filePath, {
readContentHeader: options?.inspectGeneratedHeaders !== false,
includeDeclarations: !includeDeclarations,
});
}
/**
* Extract the basename (filename without extension) from a path.
*/
function getBasename(filePath) {
const ext = path.extname(filePath);
return path.basename(filePath, ext);
}
/**
* Get the directory of a file path.
*/
function getDir(filePath) {
return path.dirname(filePath);
}
/**
* Check if a file has a higher-precedence source sibling.
* Returns the shadowing source file path if found, null otherwise.
*/
export function findSourceSibling(filePath, probeCache) {
const ext = path.extname(filePath).toLowerCase();
const dir = getDir(filePath);
const base = getBasename(filePath);
// Find which precedence group this extension belongs to
for (const [sourceExt, shadowedExts] of Object.entries(SOURCE_PRECEDENCE)) {
if (shadowedExts.includes(ext)) {
// This file could be shadowed by a source file with sourceExt
const siblingPath = path.join(dir, base + sourceExt);
if (probeExists(siblingPath, probeCache)) {
return siblingPath;
}
}
}
return null;
}
/**
* Check if a file is a build artifact (has a source sibling).
*
* @param probeCache - Optional per-walk memo (see {@link ArtifactProbeCache}).
* Omit for the original, uncached behavior.
*/
export function isBuildArtifact(filePath, probeCache) {
return findSourceSibling(filePath, probeCache) !== null;
}
/**
* Filter a list of files, removing build artifacts that have source siblings
* plus likely generated/codegen artifacts.
* Returns de-duplicated list keeping only highest-precedence source files.
*/
export function filterSourceFiles(filePaths, options) {
// Track which files we're keeping and why we're skipping others
const keep = [];
const skipReasons = new Map(); // skipped file -> kept source
// This is itself one enumeration over `filePaths`, so a per-call memo is
// safe by the same point-in-time-snapshot reasoning as the directory
// walkers below (refs #191).
const probeCache = createArtifactProbeCache();
for (const filePath of filePaths) {
const sourceSibling = findSourceSibling(filePath, probeCache);
if (sourceSibling) {
// This is a build artifact, skip it
skipReasons.set(filePath, sourceSibling);
}
else if (shouldSkipGeneratedOrArtifact(filePath, options)) {
// Generated/codegen outputs are not hand-written source.
skipReasons.set(filePath, "generated-or-artifact");
}
else {
// No higher-precedence source, keep it
keep.push(filePath);
}
}
return keep;
}
function resolveCollectionConfig(rootDir, options, config) {
const rawMax = options?.maxFiles;
const requestedMax = typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax > 0
? Math.floor(rawMax)
: Number.POSITIVE_INFINITY;
// Slow-FS mode (#462): the sync collector can't yield to the event loop, so
// on a measured-slow filesystem (9p/drvfs/NFS) clamp its walk to a much
// smaller cap regardless of what the caller asked for. The async twin
// (`collectSourceFilesAsync`) yields every N entries and keeps its normal
// cap — callers that can go async should prefer it instead of relying on
// this clamp.
const maxFiles = config?.clampForSlowFsSyncWalk === true && isSlowFs(rootDir)
? Math.min(requestedMax, SLOW_FS_REDUCED_MAX_FILES)
: requestedMax;
return {
ignoreMatcher: getProjectIgnoreMatcher(rootDir),
extraExcludePatterns: options?.excludeDirs ?? [],
extensions: new Set(options?.extensions || ALL_SCANNABLE_EXTENSIONS),
maxFiles,
options,
};
}
/**
* Decide how to handle a single directory entry. Returns the subdirectory to
* recurse into (`recurseInto`), the source file to keep (`keepFile`), or
* neither (skip). Shared verbatim by the sync and async collectors so they
* produce identical results — the only difference between the two is that the
* async variant yields to the event loop every N entries.
*/
function classifyEntry(entry, fullPath, cfg, probeCache) {
const { ignoreMatcher, extraExcludePatterns, extensions, options } = cfg;
if (entry.isDirectory()) {
const canRecurse = shouldRecurseIntoDir(entry, fullPath, {
ignoreMatcher,
extraExcludeDirs: extraExcludePatterns,
skipGeneratedArtifactDirs: options?.includeGenerated !== true,
followSymlinks: options?.followSymlinks === true,
});
if (!canRecurse)
return {};
return { recurseInto: fullPath };
}
if (entry.isFile()) {
if (ignoreMatcher.isIgnored(fullPath, false))
return {};
const ext = path.extname(entry.name).toLowerCase();
if (!extensions.has(ext))
return {};
// Skip if this is a build artifact or generated/codegen output.
if (isBuildArtifact(fullPath, probeCache))
return {};
if (shouldSkipGeneratedOrArtifact(fullPath, options))
return {};
return { keepFile: fullPath };
}
return {};
}
export function collectSourceFiles(dir, options) {
const rootDir = path.resolve(dir);
const cfg = resolveCollectionConfig(rootDir, options, {
clampForSlowFsSyncWalk: true,
});
const files = [];
// Per-walk sibling-probe memo (refs #191, item 1). Created here, discarded
// on return — never persisted across calls.
const probeCache = createArtifactProbeCache();
function scan(currentDir) {
if (files.length >= cfg.maxFiles)
return; // hard cap (#250)
const entries = readDirEntriesSafe(currentDir);
for (const entry of entries) {
if (files.length >= cfg.maxFiles)
return;
const fullPath = path.join(currentDir, entry.name);
const { recurseInto, keepFile } = classifyEntry(entry, fullPath, cfg, probeCache);
if (recurseInto)
scan(recurseInto);
else if (keepFile)
files.push(keepFile);
}
}
scan(rootDir);
return files;
}
/**
* Async, chunked-yield twin of {@link collectSourceFiles}. Returns the exact
* same file list (it shares `classifyEntry`), but yields to the event loop
* every `yieldEvery` directory entries so a large tree never holds the loop in
* one synchronous burst.
*
* Why this exists: on a ~2k-file project the synchronous `collectSourceFiles`
* blocks the loop for ~1.5s on a cold scan (≈70% of that is the per-file
* generated-header read inside `shouldSkipGeneratedOrArtifact`). When that runs
* on a hook tick — even a deferred background one — pi's TUI input stalls for
* the whole burst. Background / deferred callers should prefer this variant;
* the sync version is kept for synchronous call sites and tests.
*/
export async function collectSourceFilesAsync(dir, options) {
const rootDir = path.resolve(dir);
const cfg = resolveCollectionConfig(rootDir, options);
// 50 entries/chunk keeps the worst-case synchronous burst under ~40ms even
// on a cold scan where every kept file pays the 4 KB generated-header read
// (measured on a 2k-file fixture). Larger values regress past the ~50ms
// event-loop budget; see PERF-AUDIT.md.
const yieldEvery = Math.max(1, options?.yieldEvery ?? 50);
const files = [];
// Depth-first stack mirrors the recursion order of the sync collector.
const stack = [rootDir];
let processedSinceYield = 0;
// Per-walk sibling-probe memo (refs #191, item 1). A single async walk is
// still one point-in-time snapshot despite yielding between chunks, so
// caching across the whole call remains invalidation-free.
const probeCache = createArtifactProbeCache();
while (stack.length > 0) {
const currentDir = stack.pop();
if (currentDir === undefined)
continue;
const entries = readDirEntriesSafe(currentDir);
// Push subdirectories in reverse so the deepest-first pop order matches
// the sync collector's left-to-right recursion within a directory.
const subDirs = [];
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const { recurseInto, keepFile } = classifyEntry(entry, fullPath, cfg, probeCache);
if (recurseInto)
subDirs.push(recurseInto);
else if (keepFile) {
files.push(keepFile);
if (files.length >= cfg.maxFiles)
return files; // hard cap (#250)
}
if (++processedSinceYield >= yieldEvery) {
processedSinceYield = 0;
await new Promise((resolve) => setImmediate(resolve));
}
}
for (let i = subDirs.length - 1; i >= 0; i--)
stack.push(subDirs[i]);
}
return files;
}
/**
* Get statistics about source file filtering for debugging/monitoring.
*/
export function getFilterStats(allFiles, filteredFiles) {
const skipped = allFiles.length - filteredFiles.length;
const byType = {};
// Count what we skipped
for (const file of allFiles) {
if (!filteredFiles.includes(file)) {
const ext = path.extname(file).toLowerCase();
byType[ext] = (byType[ext] || 0) + 1;
}
}
return {
total: allFiles.length,
kept: filteredFiles.length,
skipped,
byType,
};
}