feat(review): /review 코드 리뷰 map-reduce 청킹 명령 (v2.2.255)
일반 에이전트 채팅이 큰 코드베이스 리뷰를 단일 호출로 처리하다 약한 로컬 모델에서 빈 응답으로 무너지던 문제를, /meet 의 검증된 map-reduce 로 우회. - /review <디렉터리|파일> [초점] 신설 (코어 채팅 경로 무수정) - Map: 파일별 독립 리뷰(라인 인용 근거), callLmSynthesis 재시도/붕괴감지 활용, 한 파일 실패해도 부분 리뷰로 진행 - Reduce: 노트 통합 + hierarchical fold 로 reduce 입력을 약한 모델 한도(16K) 안 유지 - 의존성/빌드 산출물 제외, 파일 30개·400KB 상한, 결과 wiki 저장 - 신규 reviewPrompt.ts / reviewFiles.ts, 테스트 +5건(전체 667 통과) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* /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<CollectResult> {
|
||||
const found: CollectedFile[] = [];
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user