/** * /review 대상 소스 파일 수집기. * * 디렉터리를 재귀 순회하며 "리뷰할 가치가 있는 소스 파일"만 골라낸다. * 의존성·빌드 산출물·바이너리·생성물은 제외한다. 순수 판정 로직(shouldReviewFile)은 * fs 와 분리해 테스트 가능하게 둔다. */ import { promises as fsp } from 'fs'; import * as path from 'path'; /** 리뷰 대상 소스 확장자 (소문자, 점 포함). */ export const REVIEW_EXTENSIONS = new Set([ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.java', '.go', '.rs', '.rb', '.php', '.c', '.cc', '.cpp', '.h', '.hpp', '.cs', '.kt', '.swift', '.scala', '.vue', '.svelte', '.sql', '.sh', ]); /** 순회에서 통째로 건너뛸 디렉터리 이름. */ export const SKIP_DIRS = new Set([ 'node_modules', '.git', 'out', 'dist', 'build', '.next', 'coverage', 'vendor', '.venv', 'venv', '__pycache__', '.astra', '.secondbrain', '.vscode', '.idea', 'bin', 'obj', 'target', 'media', 'assets', ]); /** * 상대 경로(슬래시 정규화)가 리뷰 대상인지 판정. 디렉터리 스킵·확장자 필터· * 생성물(.min.js / .d.ts / *.map / lock) 제외를 한곳에서 결정한다. */ export function shouldReviewFile(relPathPosix: string): boolean { const parts = relPathPosix.split('/'); const base = parts[parts.length - 1].toLowerCase(); // 스킵 디렉터리가 경로 어딘가에 있으면 제외 if (parts.slice(0, -1).some((seg) => SKIP_DIRS.has(seg))) return false; // 생성물·노이즈 제외 if (base.endsWith('.min.js') || base.endsWith('.d.ts') || base.endsWith('.map')) return false; if (base === 'package-lock.json' || base === 'yarn.lock' || base === 'pnpm-lock.yaml') return false; const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : ''; return REVIEW_EXTENSIONS.has(ext); } export interface CollectedFile { /** 절대 경로. */ absPath: string; /** 루트 기준 상대 경로(슬래시). */ relPath: string; /** 바이트 크기. */ size: number; } export interface CollectOptions { /** 수집 파일 수 상한. 초과분은 잘리며 truncated=true. */ maxFiles: number; /** 파일 1개 최대 바이트(이보다 크면 제외 — 거대 생성물 방어). */ maxFileBytes: number; } export interface CollectResult { files: CollectedFile[]; /** maxFiles 초과로 잘렸는가. */ truncated: boolean; /** 순회 중 본 리뷰 대상 후보 총수(상한 적용 전). */ totalCandidates: number; } /** * root 디렉터리를 재귀 순회해 리뷰 대상 파일을 수집한다. * 결정적 순서(경로 정렬)로 반환해 재실행 시 동일 결과가 나오게 한다. */ export async function collectSourceFiles(root: string, opts: CollectOptions): Promise { const found: CollectedFile[] = []; const walk = async (dir: string): Promise => { let entries: import('fs').Dirent[]; try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; // 권한 등으로 못 읽는 디렉터리는 건너뜀 } for (const ent of entries) { const abs = path.join(dir, ent.name); if (ent.isDirectory()) { if (SKIP_DIRS.has(ent.name)) continue; await walk(abs); } else if (ent.isFile()) { const rel = path.relative(root, abs).split(path.sep).join('/'); if (!shouldReviewFile(rel)) continue; let size = 0; try { size = (await fsp.stat(abs)).size; } catch { continue; } if (size > opts.maxFileBytes) continue; found.push({ absPath: abs, relPath: rel, size }); } } }; await walk(root); found.sort((a, b) => a.relPath.localeCompare(b.relPath)); const truncated = found.length > opts.maxFiles; return { files: truncated ? found.slice(0, opts.maxFiles) : found, truncated, totalCandidates: found.length, }; }