UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

157 lines 5.25 kB
import { readdirSync, statSync } from 'fs'; import { basename, join, relative } from 'path'; import { MAX_DIRECTORY_DEPTH, MAX_FILES_TO_SCAN } from '../constants.js'; import { loadGitignore } from '../utils/gitignore-loader.js'; export class FileScanner { rootPath; ignoreInstance; maxFiles = MAX_FILES_TO_SCAN; // Prevent scanning massive codebases maxDepth = MAX_DIRECTORY_DEPTH; // Prevent infinite recursion constructor(rootPath) { this.rootPath = rootPath; this.ignoreInstance = loadGitignore(rootPath); } /** * Check if a file/directory should be ignored based on .gitignore */ shouldIgnore(filePath) { const relativePath = relative(this.rootPath, filePath); // ignore library requires non-empty paths if (!relativePath || relativePath === '.') { return false; } return this.ignoreInstance.ignores(relativePath); } /** * Recursively scan directory for files */ scan() { const result = { files: [], directories: [], totalFiles: 0, scannedFiles: 0, }; this.scanDirectory(this.rootPath, result, 0); return result; } /** * Recursively scan a directory */ scanDirectory(dirPath, result, depth) { if (depth > this.maxDepth || result.scannedFiles >= this.maxFiles) { return; } if (this.shouldIgnore(dirPath)) { return; } try { const entries = readdirSync(dirPath); for (const entry of entries) { if (result.scannedFiles >= this.maxFiles) { break; } // nosemgrep const fullPath = join(dirPath, entry); // nosemgrep const relativePath = relative(this.rootPath, fullPath); if (this.shouldIgnore(fullPath)) { continue; } try { const stats = statSync(fullPath); if (stats.isDirectory()) { result.directories.push(relativePath); this.scanDirectory(fullPath, result, depth + 1); } else if (stats.isFile()) { result.files.push(relativePath); result.scannedFiles++; } result.totalFiles++; } catch { // Skip files we can't stat (permission issues, etc.) continue; } } } catch { // Skip directories we can't read return; } } /** * Convert a simple glob-like pattern (using '*' as wildcard) to a RegExp. * Escapes regex metacharacters before expanding '*' to '.*'. */ globToRegExp(pattern) { // Validate pattern to prevent ReDoS - only allow safe glob patterns if (pattern.length > 1000) { throw new Error('Pattern too long'); } // Only allow safe characters in glob patterns if (/[^a-zA-Z0-9_\-./\\*?+[\]{}^$|()]/.test(pattern)) { throw new Error('Pattern contains unsafe characters'); } // Escape all regex metacharacters, then replace escaped '*' with '.*' const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regexSource = escaped.replace(/\\\*/g, '.*'); return new RegExp(regexSource, 'i'); /* nosemgrep */ } /** * Get files matching specific patterns */ getFilesByPattern(patterns) { const scanResult = this.scan(); return scanResult.files.filter(file => patterns.some(pattern => { const regex = this.globToRegExp(pattern); return regex.test(file) || regex.test(basename(file)); })); } /** * Get key project files */ getProjectFiles() { return { config: this.getFilesByPattern([ 'package.json', 'requirements.txt', 'Cargo.toml', 'go.mod', 'composer.json', 'Gemfile', 'setup.py', 'pyproject.toml', 'yarn.lock', 'pnpm-lock.yaml', ]), documentation: this.getFilesByPattern([ 'README*', 'CHANGELOG*', 'LICENSE*', 'CONTRIBUTING*', '*.md', 'docs/*', ]), build: this.getFilesByPattern([ 'Makefile', 'CMakeLists.txt', 'build.gradle', 'webpack.config.*', 'vite.config.*', 'rollup.config.*', 'tsconfig.json', 'jsconfig.json', ]), test: this.getFilesByPattern([ '*test*', '*spec*', '__tests__/*', 'test/*', 'tests/*', 'spec/*', ]), }; } } //# sourceMappingURL=file-scanner.js.map