@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
JavaScript
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