From e30ab87b38d6513099187c674d5d2da928a723ff Mon Sep 17 00:00:00 2001 From: Dino0204 Date: Fri, 21 Aug 2026 11:41:16 +0900 Subject: [PATCH] =?UTF-8?q?add(review):=20=EB=A6=AC=EB=B7=B0=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=EC=97=90=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=EA=B3=BC=20read=5Ffile=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.ts | 21 +++++ src/llm.ts | 4 +- src/review/prompt.ts | 87 +++++++++++++++-- src/review/runner.ts | 215 +++++++++++++++++++++++++++++++++++++++---- src/review/source.ts | 158 +++++++++++++++++++++++++++++++ 5 files changed, 459 insertions(+), 26 deletions(-) create mode 100644 src/review/source.ts diff --git a/src/config.ts b/src/config.ts index 3bdc758..8bbb143 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,13 @@ export interface BotConfig { /** 파일 하나를 컨텍스트에 넣을 때의 최대 문자 수 */ maxFileChars: number + /** diff와 함께 변경된 파일의 현재 내용도 싣는다 */ + includeSources: boolean + /** 파일 하나의 현재 내용에 쓸 최대 문자 수 */ + maxSourceChars: number + /** 모델이 read_file로 더 읽어갈 수 있는 파일 수 (0이면 도구를 주지 않는다) */ + maxExtraReads: number + /** 리뷰에서 제외할 glob */ exclude: string[] /** 지정 시 이 glob에 매칭되는 파일만 리뷰 */ @@ -65,6 +72,12 @@ export const DEFAULT_CONFIG: BotConfig = { maxFiles: 40, maxFileChars: 24_000, + // diff만 주면 헝크 밖을 알 수 없어, 손대지 않은 줄을 새로 생긴 것으로 읽는 오탐이 나온다. + // 원본을 함께 실으면 프롬프트가 커지지만 그만큼 지적의 근거가 확실해진다. + includeSources: true, + maxSourceChars: 16_000, + maxExtraReads: 6, + exclude: [ '**/node_modules/**', '**/dist/**', @@ -132,6 +145,8 @@ function pickFileConfig(raw: unknown): Partial { 'maxPromptChars', 'maxFiles', 'maxFileChars', + 'maxSourceChars', + 'maxExtraReads', 'maxInlineComments', ] as const for (const key of numbers) { @@ -140,6 +155,7 @@ function pickFileConfig(raw: unknown): Partial { if (typeof r['autoReview'] === 'boolean') out.autoReview = r['autoReview'] if (typeof r['threadReply'] === 'boolean') out.threadReply = r['threadReply'] + if (typeof r['includeSources'] === 'boolean') out.includeSources = r['includeSources'] const exclude = coerceStringArray(r['exclude']) if (exclude) out.exclude = [...DEFAULT_CONFIG.exclude, ...exclude] @@ -165,6 +181,11 @@ function envOverrides(): Partial { } if (env['REVIEWBOT_AUTO_REVIEW']) out.autoReview = env['REVIEWBOT_AUTO_REVIEW'] !== 'false' if (env['REVIEWBOT_THREAD_REPLY']) out.threadReply = env['REVIEWBOT_THREAD_REPLY'] !== 'false' + if (env['REVIEWBOT_INCLUDE_SOURCES']) out.includeSources = env['REVIEWBOT_INCLUDE_SOURCES'] !== 'false' + if (env['REVIEWBOT_MAX_EXTRA_READS']) { + const n = Number(env['REVIEWBOT_MAX_EXTRA_READS']) + if (Number.isFinite(n)) out.maxExtraReads = n + } if (env['REVIEWBOT_MAX_FILES']) { const n = Number(env['REVIEWBOT_MAX_FILES']) if (Number.isFinite(n)) out.maxFiles = n diff --git a/src/llm.ts b/src/llm.ts index abb1fb7..2b8c7fd 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -37,6 +37,8 @@ export interface ToolChatResult { toolCalls: ToolCall[] /** 도구 호출을 걷어낸 나머지 본문 (`` 블록 제외) */ text: string + /** `` 만 걷어낸 원문. 대화를 이어갈 때 assistant 차례로 되싣는다 */ + raw: string } export class LlmError extends Error { @@ -229,7 +231,7 @@ export function parseToolCalls(content: string): ToolChatResult { toolCalls.push({ name, arguments: args }) } - return { toolCalls, text: content.replace(TOOL_CALL_PATTERN, '').trim() } + return { toolCalls, text: content.replace(TOOL_CALL_PATTERN, '').trim(), raw: content.trim() } } /** diff --git a/src/review/prompt.ts b/src/review/prompt.ts index 95a27c3..6490eac 100644 --- a/src/review/prompt.ts +++ b/src/review/prompt.ts @@ -3,6 +3,8 @@ import type { BotConfig } from '../config' import type { PullRequestInfo, ReviewThread } from '../github/client' import type { DiffFile } from '../github/diff' import { renderFileDiff } from '../github/diff' +import type { FileSource } from './source' +import { renderFileSource } from './source' import { BOT_MENTION, SEVERITIES } from '../config' /** 리포지토리가 코드 작성자를 위해 두고 있는 지침 문서 (AGENTS.md 등) */ @@ -16,6 +18,8 @@ export interface ReviewContext { config: BotConfig pr: PullRequestInfo diffFiles: DiffFile[] + /** 변경된 파일들의 현재 내용. 읽지 못했거나 설정으로 껐으면 비어 있다 */ + sources?: FileSource[] instructions?: RepoInstructions } @@ -59,6 +63,13 @@ export function buildSystemPrompt(config: BotConfig): string { 'diff의 각 줄 왼쪽에 붙은 숫자가 **변경 후 파일의 줄 번호**다. `line` 필드에는 반드시 그 숫자를 쓴다.', '숫자가 비어 있는 줄(삭제된 줄)은 인라인 코멘트를 달 수 없으므로, 그 문제는 summary에 서술한다.', '', + '## diff를 읽을 때 주의할 것', + 'diff의 `+`/`-` 는 "무엇이 바뀌었나"가 아니라 git이 두 판본을 줄 단위로 맞춰본 결과다.', + '블록이 새로 끼어들면 **손대지 않은 줄도 `+` 로 찍힌다**. 그 줄을 이번에 추가된 것으로 읽으면,', + '원래부터 있던 코드를 "이번에 고쳐졌다"거나 "전에는 빠져 있었다"고 잘못 단정하게 된다.', + '어떤 줄이 실제로 새로 생겼는지 확신이 필요하면 아래 실린 파일의 현재 내용과 대조해 확인한다.', + '기존 코드가 어떠했는지에 대한 주장은 diff만으로는 근거가 되지 않는다.', + '', '## suggestion 필드', 'GitHub에서 이 값은 "이 코드로 교체" 버튼이 된다. 따라서 **오직 소스 코드만** 들어갈 수 있다.', '`line` 줄을 그대로 대체할 수 있는 완성된 코드일 때만 채우고, 들여쓰기까지 정확히 맞춘다.', @@ -75,12 +86,24 @@ export function buildSystemPrompt(config: BotConfig): string { `2. 지적할 것이 있으면 발견마다 \`${FINDING_TOOL}\` 을 한 번씩 호출한다.`, '지적할 것이 없으면 요약만 호출하고 끝낸다.', '', + ...(config.maxExtraReads > 0 + ? [ + `## ${READ_TOOL}`, + '판단에 필요한 코드가 실려 있지 않으면 추측하지 말고 그 파일을 읽는다.', + '이번 diff에 없는 파일도 읽을 수 있다 — 호출되는 함수의 정의, 타입 선언, 설정 파일 등이다.', + `읽고 싶을 때는 \`${READ_TOOL}\` 만 호출하고 다른 도구는 함께 부르지 않는다.`, + '내용을 받은 뒤 다음 차례에 리뷰를 제출한다.', + `읽을 수 있는 파일 수에는 상한(${config.maxExtraReads}개)이 있으므로 꼭 필요한 것만 고른다.`, + '', + ] + : []), `본문(summary, title, detail)은 ${languageName(config.language)}로 작성한다. 코드/식별자/에러 메시지는 원문 그대로 둔다.`, ].join('\n') } export const SUMMARY_TOOL = 'submit_summary' export const FINDING_TOOL = 'submit_inline_comment' +export const READ_TOOL = 'read_file' /** * 모델에게 제시할 도구. @@ -121,6 +144,25 @@ export function reviewTools(config: BotConfig): ToolDefinition[] { required: ['file', 'line', 'severity', 'title', 'detail'], }, }, + ...(config.maxExtraReads > 0 + ? [ + { + name: READ_TOOL, + description: + '리뷰에 필요한 파일의 현재 내용을 읽는다. 이번 diff에 없는 파일도 읽을 수 있다. 이 도구를 부를 때는 다른 도구를 함께 호출하지 않는다.', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: '리포지토리 루트 기준 파일 경로. 예: src/review/runner.ts', + }, + }, + required: ['path'], + }, + }, + ] + : []), ] } @@ -139,19 +181,45 @@ function prMeta(pr: PullRequestInfo): string { .join('\n') } -function renderDiff(context: ReviewContext): string { - const text = context.diffFiles.map((file) => renderFileDiff(file, context.config.maxFileChars)).join('\n\n') - // 시스템 프롬프트와 PR 메타가 쓰는 몫을 빼고 나머지를 diff에 준다. - // 지침 문서는 자르지 않으므로 그만큼 diff 예산에서 뺀다 — 다만 큰 지침 문서 하나가 - // diff를 통째로 밀어내지 않도록 하한을 둔다. - const { maxPromptChars } = context.config - const budget = Math.max( +/** + * diff와 파일 원본이 나눠 쓸 예산. + * + * 지침 문서는 자르지 않으므로 먼저 빼둔다 — 다만 큰 지침 문서 하나가 코드를 통째로 + * 밀어내지 않도록 하한을 둔다. 원본을 싣지 않는 설정이면 diff가 예산을 다 쓴다. + */ +function promptBudgets(context: ReviewContext): { diff: number; sources: number } { + const { maxPromptChars, includeSources } = context.config + const available = Math.max( Math.floor(maxPromptChars * 0.85) - (context.instructions?.content.length ?? 0), Math.floor(maxPromptChars * 0.3), ) + if (!includeSources || !context.sources?.length) return { diff: available, sources: 0 } + return { diff: Math.floor(available * 0.45), sources: Math.floor(available * 0.55) } +} + +function renderDiff(context: ReviewContext, budget: number): string { + const text = context.diffFiles.map((file) => renderFileDiff(file, context.config.maxFileChars)).join('\n\n') return truncate(text, budget) } +/** + * 변경된 파일들의 현재 내용을 싣는다. + * + * diff는 바뀐 줄과 그 주변 몇 줄만 보여주므로, 함수 하나가 어떻게 생겼는지도 알 수 없다. + * 그 상태로는 "이 값이 어디서 오는가" 같은 질문에 모델이 추측으로 답하게 된다. + */ +function sourcesSection(sources: FileSource[], budget: number): string { + const rendered = sources.map((source) => renderFileSource(source)).join('\n\n') + return [ + '', + '## 변경된 파일의 현재 내용', + 'diff에 실린 것과 같은 파일들의 변경 후 전체 내용이다. 헝크 밖 맥락은 여기서 확인한다.', + '어떤 줄이 이번에 새로 생겼는지 판단할 때도 diff의 `+` 표시보다 이쪽을 근거로 삼는다.', + '', + truncate(rendered, budget), + ].join('\n') +} + /** * 리포지토리 지침을 프롬프트에 싣는다. * @@ -171,6 +239,8 @@ function instructionsSection(instructions: RepoInstructions): string { export function buildReviewMessages(context: ReviewContext): ChatMessage[] { const { config } = context + const budgets = promptBudgets(context) + const sources = config.includeSources ? (context.sources ?? []) : [] const userPrompt = [ '아래 Pull Request를 리뷰하라.', @@ -179,7 +249,8 @@ export function buildReviewMessages(context: ReviewContext): ChatMessage[] { prMeta(context.pr), '', '## 변경 사항 (diff)', - renderDiff(context), + renderDiff(context, budgets.diff), + sources.length ? sourcesSection(sources, budgets.sources) : '', context.instructions ? instructionsSection(context.instructions) : '', `\n${SUMMARY_TOOL} 을 반드시 호출하고, 지적할 것이 있으면 ${FINDING_TOOL} 도 함께 호출하라.`, ].join('\n') diff --git a/src/review/runner.ts b/src/review/runner.ts index 942c3dc..f2871ec 100644 --- a/src/review/runner.ts +++ b/src/review/runner.ts @@ -1,12 +1,14 @@ import { minimatch } from 'minimatch' import type { BotConfig } from '../config' import { meetsSeverity } from '../config' -import type { LlmClient, ToolCall } from '../llm' +import type { ChatMessage, LlmClient, ToolCall } from '../llm' import type { GitHubClient, InlineComment, PullRequestInfo } from '../github/client' import type { DiffFile } from '../github/diff' import { parseUnifiedDiff, renderFileDiff, snapToCommentableLine } from '../github/diff' -import { FINDING_TOOL, SUMMARY_TOOL, buildReviewMessages, reviewTools } from './prompt' +import { FINDING_TOOL, READ_TOOL, SUMMARY_TOOL, buildReviewMessages, reviewTools } from './prompt' import type { RepoInstructions, ReviewContext } from './prompt' +import type { FileSource } from './source' +import { buildFileSource, renderPlainSource, sourceLength } from './source' import { findingSchema } from './schema' import type { Finding, RawFinding, ReviewResult } from './schema' import { renderFindingComment, renderPlainComment, renderReviewSummary } from './render' @@ -34,12 +36,45 @@ async function gatherContext(deps: RunnerDeps, pr: PullRequestInfo): Promise { + const loaded = await Promise.all( + files.map(async (file) => { + try { + const content = await github.readFile(file.path, ref) + if (content === undefined) return undefined + return buildFileSource(file, content, config.maxSourceChars) + } catch (error) { + log.warn(`파일 원본 읽기 실패(무시): ${file.path} — ${error instanceof Error ? error.message : String(error)}`) + return undefined + } + }), + ) + + const sources = loaded.filter((source): source is FileSource => source !== undefined) + const partial = sources.filter((source) => source.partial).length + log.info(`파일 원본 ${sources.length}/${files.length}개 로드` + (partial ? ` (${partial}개는 발췌)` : '')) + return sources +} + export function filterFiles(files: DiffFile[], config: BotConfig): { selected: DiffFile[]; skipped: number } { const matched = files.filter((file) => { if (file.isBinary) return false @@ -61,14 +96,20 @@ export function filterFiles(files: DiffFile[], config: BotConfig): { selected: D * 지침 문서는 청크마다 다시 실리므로 그 길이를 청크 예산에서 미리 뺀다. * 다만 파일 하나는 언제나 담을 수 있어야 하므로 maxFileChars를 하한으로 둔다. */ -export function chunkFiles(files: DiffFile[], config: BotConfig, instructionChars = 0): DiffFile[][] { +export function chunkFiles( + files: DiffFile[], + config: BotConfig, + instructionChars = 0, + sourceSizes?: Map, +): DiffFile[][] { const diffBudget = Math.max(Math.floor(config.maxPromptChars * 0.5) - instructionChars, config.maxFileChars) const chunks: DiffFile[][] = [] let current: DiffFile[] = [] let size = 0 for (const file of files) { - const rendered = renderFileDiff(file, config.maxFileChars).length + // 원본을 함께 싣는 설정이면 그 길이도 청크에 포함된다 — 빼놓으면 청크가 예산을 넘긴다 + const rendered = renderFileDiff(file, config.maxFileChars).length + (sourceSizes?.get(file.path) ?? 0) if (current.length > 0 && size + rendered > diffBudget) { chunks.push(current) current = [] @@ -101,14 +142,21 @@ export async function runReview(deps: RunnerDeps, pr: PullRequestInfo): Promise< return { posted: true, findings: 0, inline: 0 } } - const chunks = chunkFiles(context.diffFiles, config, context.instructions?.content.length ?? 0) + const sources = context.sources ?? [] + const sourceSizes = new Map(sources.map((source) => [source.path, sourceLength(source)])) + const chunks = chunkFiles(context.diffFiles, config, context.instructions?.content.length ?? 0, sourceSizes) log.info(`리뷰 청크 ${chunks.length}개`) const results: ReviewResult[] = [] for (const [index, chunk] of chunks.entries()) { if (chunks.length > 1) log.info(`청크 ${index + 1}/${chunks.length} 리뷰 중 (${chunk.length}개 파일)`) - const chunkContext: ReviewContext = { ...context, diffFiles: chunk } - results.push(await requestReview(llm, chunkContext, config)) + const paths = new Set(chunk.map((file) => file.path)) + const chunkContext: ReviewContext = { + ...context, + diffFiles: chunk, + sources: sources.filter((source) => paths.has(source.path)), + } + results.push(await requestReview(deps, chunkContext, config)) } const merged = mergeResults(results) @@ -146,29 +194,68 @@ export async function runReview(deps: RunnerDeps, pr: PullRequestInfo): Promise< } } +/** 도구를 부르지 않은 응답을 몇 번까지 교정 요청할지 */ +const MAX_NUDGES = 2 + /** * 모델에게 도구를 제시해 리뷰 결과를 받는다. * + * 모델이 `read_file` 을 부르면 그 파일을 읽어 대화에 실어주고 다시 묻는다 — + * diff에 없는 코드가 판단에 필요할 때 추측 대신 확인하게 하려는 것이다. + * 리뷰 도구를 부른 시점에 대화가 끝나므로, 읽기 요청이 계속되면 상한에서 멈춘다. + * * 도구 주입은 그래머 강제가 아니라 프롬프트 기반이므로 모델이 도구를 아예 안 부를 수 있다. * 그 경우 한 번 더 시도하고, 그래도 못 받으면 모델이 쓴 본문을 요약으로 대신 실어 * 인라인 코멘트 없이도 리뷰가 통째로 사라지지 않게 한다. */ -async function requestReview(llm: LlmClient, context: ReviewContext, config: BotConfig): Promise { - const messages = buildReviewMessages(context) +async function requestReview( + deps: RunnerDeps, + context: ReviewContext, + config: BotConfig, +): Promise { + const { llm, github } = deps const tools = reviewTools(config) const options = { temperature: config.temperature, maxTokens: config.maxOutputTokens } + const conversation: ChatMessage[] = buildReviewMessages(context) + // diff에 실린 파일은 이미 원본까지 넘겼으므로 다시 읽어줄 이유가 없다 + const served = new Set(context.diffFiles.map((file) => file.path)) + let reads = 0 + let nudges = 0 let lastText = '' - for (let attempt = 1; attempt <= 2; attempt++) { - // 재시도는 같은 프롬프트를 그대로 다시 보내는 대신 무엇이 잘못됐는지 알려준다. - const attemptMessages = - attempt === 1 ? messages : [...messages, { role: 'user' as const, content: retryNudge() }] - const { toolCalls, text } = await llm.chatWithTools(attemptMessages, tools, options) - if (toolCalls.length > 0) return collectToolCalls(toolCalls) + const maxRounds = MAX_NUDGES + config.maxExtraReads + 1 + for (let round = 1; round <= maxRounds; round++) { + const { toolCalls, text, raw } = await llm.chatWithTools(conversation, tools, options) + + const readCalls = toolCalls.filter((call) => call.name === READ_TOOL) + const reviewCalls = toolCalls.filter((call) => call.name !== READ_TOOL) + + // 리뷰를 제출했으면 그것으로 끝낸다. 읽기 요청을 함께 부른 경우 읽어주지 않는다 — + // 이미 판단을 내려놓고 부른 것이라 한 번 더 물어도 같은 답이 돌아온다. + if (reviewCalls.length > 0) { + if (readCalls.length > 0) { + log.warn(`리뷰 제출과 ${READ_TOOL} 을 함께 호출해 읽기 요청은 무시한다`) + } + return collectToolCalls(reviewCalls) + } + + if (readCalls.length > 0) { + const { message, granted } = await serveReads(github, readCalls, context, config, served, reads) + reads += granted + conversation.push({ role: 'assistant', content: raw }) + conversation.push({ role: 'user', content: message }) + continue + } lastText = text - log.warn(`모델이 도구를 호출하지 않았다 (${attempt}/2)`) + nudges++ + log.warn(`모델이 도구를 호출하지 않았다 (${nudges}/${MAX_NUDGES})`) + if (nudges >= MAX_NUDGES) break + + // 재시도는 같은 프롬프트를 그대로 다시 보내는 대신 무엇이 잘못됐는지 알려준다. + conversation.push({ role: 'assistant', content: raw }) + conversation.push({ role: 'user', content: retryNudge() }) } return { @@ -177,6 +264,100 @@ async function requestReview(llm: LlmClient, context: ReviewContext, config: Bot } } +/** + * 모델이 요청한 파일을 읽어 다음 차례에 실어줄 메시지를 만든다. + * + * 경로는 모델이 지어낸 문자열이므로 그대로 쓰지 않는다 — 리포지토리 밖을 가리키거나 + * 제외 대상인 경로는 거절하고, 거절한 이유도 함께 알려준다. 아무 말 없이 비워 보내면 + * 모델이 같은 파일을 계속 다시 요청한다. + */ +async function serveReads( + github: GitHubClient, + calls: ToolCall[], + context: ReviewContext, + config: BotConfig, + served: Set, + used: number, +): Promise<{ message: string; granted: number }> { + const parts: string[] = [] + let granted = 0 + + for (const call of calls) { + const requested = call.arguments['path'] ?? '' + const path = normalizeReadPath(requested) + + if (!path) { + parts.push(`\`${requested}\` — 경로를 해석할 수 없어 읽지 않았다.`) + continue + } + if (served.has(path)) { + parts.push(`\`${path}\` — 이미 위에 실려 있다. 그 내용을 보라.`) + continue + } + if (used + granted >= config.maxExtraReads) { + parts.push(`\`${path}\` — 읽기 상한(${config.maxExtraReads}개)에 도달해 읽지 않았다. 지금 있는 자료로 리뷰를 제출하라.`) + continue + } + if (config.exclude.some((pattern) => minimatch(path, pattern, { dot: true }))) { + parts.push(`\`${path}\` — 리뷰에서 제외된 경로라 읽지 않았다.`) + continue + } + + let content: string | undefined + try { + content = await github.readFile(path, context.pr.headSha) + } catch (error) { + log.warn(`${READ_TOOL} 실패: ${path} — ${error instanceof Error ? error.message : String(error)}`) + parts.push(`\`${path}\` — 읽는 중 오류가 나 내용을 가져오지 못했다.`) + continue + } + if (content === undefined) { + parts.push(`\`${path}\` — 이 리포지토리에 없는 파일이다.`) + continue + } + + served.add(path) + granted++ + log.info(`${READ_TOOL}: ${path} (${content.length}자)`) + parts.push(renderPlainSource(path, content, config.maxSourceChars)) + } + + const remaining = config.maxExtraReads - (used + granted) + const message = [ + `## ${READ_TOOL} 결과`, + '', + ...parts, + '', + remaining > 0 + ? `더 읽을 수 있는 파일은 ${remaining}개다. 필요 없으면 지금 리뷰를 제출하라.` + : '읽기 상한에 도달했다. 지금 있는 자료로 리뷰를 제출하라.', + ].join('\n') + + return { message, granted } +} + +/** + * 모델이 준 경로를 리포지토리 안의 상대 경로로 정규화한다. + * + * 값을 검증 없이 API에 넘기면 모델이 지어낸 경로로 엉뚱한 요청을 보내게 된다. + * 해석할 수 없으면 고쳐 쓰지 않고 undefined — 애매한 경로를 추측해 읽어주는 것보다 + * 읽지 못했다고 알리는 편이 낫다. + */ +export function normalizeReadPath(raw: string): string | undefined { + const cleaned = raw + .trim() + .replace(/^['"`]|['"`]$/g, '') + .replace(/^\/+/, '') + .replace(/^\.\//, '') + .replace(/^[ab]\//, '') + + if (!cleaned || cleaned.length > 400) return undefined + if (cleaned.includes('\0') || cleaned.includes('\n')) return undefined + // `..` 로 리포지토리 밖을 가리키는 경로는 거절한다 + if (cleaned.split('/').some((segment) => segment === '..')) return undefined + return cleaned +} + /** 도구를 부르지 않은 응답에 붙이는 교정 지시. 형식은 도구 블록에 이미 있으므로 되풀이하지 않는다. */ function retryNudge(): string { return [ diff --git a/src/review/source.ts b/src/review/source.ts new file mode 100644 index 0000000..2c28bcf --- /dev/null +++ b/src/review/source.ts @@ -0,0 +1,158 @@ +import type { DiffFile } from '../github/diff' + +/** 원본에서 잘라낸 연속 구간 하나 */ +export interface SourceRegion { + /** lines[0] 의 파일 내 줄 번호 (1부터) */ + startLine: number + lines: string[] +} + +/** 프롬프트에 실을 파일 하나의 현재 내용 */ +export interface FileSource { + path: string + /** 원본 전체 줄 수 — 발췌가 얼마나 잘렸는지 모델이 가늠할 수 있게 한다 */ + totalLines: number + /** 전체를 싣지 못하고 일부만 실었는가 */ + partial: boolean + regions: SourceRegion[] +} + +/** 변경 구간 앞뒤로 함께 실을 줄 수. 함수 하나가 통째로 들어올 만큼은 되어야 한다 */ +export const SOURCE_RADIUS = 40 + +interface Range { + start: number + end: number +} + +/** + * diff의 각 헝크가 가리키는 범위를 앞뒤로 넓힌 뒤, 겹치거나 맞닿는 것끼리 합친다. + * + * 헝크 경계에서 끊긴 조각을 여러 개 주면 모델이 같은 함수를 여러 번 읽은 것으로 착각한다. + */ +function mergedRanges(file: DiffFile, totalLines: number, radius: number): Range[] { + const ranges: Range[] = file.hunks.map((hunk) => ({ + start: Math.max(1, hunk.newStart - radius), + end: Math.min(totalLines, hunk.newStart + Math.max(hunk.newLines, 1) - 1 + radius), + })) + + ranges.sort((a, b) => a.start - b.start) + + const merged: Range[] = [] + for (const range of ranges) { + const last = merged[merged.length - 1] + // 사이가 한 줄뿐이면 붙여버린다 — 구분선을 넣는 것보다 그냥 잇는 편이 읽기 쉽다 + if (last && range.start <= last.end + 1) { + last.end = Math.max(last.end, range.end) + continue + } + merged.push({ ...range }) + } + return merged +} + +/** + * 파일의 현재 내용에서 프롬프트에 실을 부분을 고른다. + * + * 예산 안에 들면 파일 전체를 싣는다 — 리뷰어가 diff 밖을 확인하려면 그게 가장 확실하다. + * 넘치면 변경 구간 주변만 남긴다. 앞쪽 구간부터 채우고, 예산이 떨어지면 거기서 멈춘다. + */ +export function buildFileSource( + file: DiffFile, + content: string, + maxChars: number, + radius = SOURCE_RADIUS, +): FileSource { + const lines = content.split('\n') + const totalLines = lines.length + + if (content.length <= maxChars) { + return { path: file.path, totalLines, partial: false, regions: [{ startLine: 1, lines }] } + } + + const regions: SourceRegion[] = [] + let used = 0 + let partial = true + + for (const range of mergedRanges(file, totalLines, radius)) { + const slice = lines.slice(range.start - 1, range.end) + const size = slice.reduce((sum, line) => sum + line.length + 1, 0) + if (used + size > maxChars) break + regions.push({ startLine: range.start, lines: slice }) + used += size + } + + // 헝크 주변조차 예산을 넘으면 첫 구간을 잘라서라도 싣는다 — 빈손보다는 낫다 + if (regions.length === 0) { + const first = mergedRanges(file, totalLines, radius)[0] + if (first) { + const slice = lines.slice(first.start - 1, first.end) + const kept: string[] = [] + let size = 0 + for (const line of slice) { + if (size + line.length + 1 > maxChars) break + kept.push(line) + size += line.length + 1 + } + if (kept.length) regions.push({ startLine: first.start, lines: kept }) + } + } + + if (regions.length === 1 && regions[0]!.startLine === 1 && regions[0]!.lines.length === totalLines) { + partial = false + } + + return { path: file.path, totalLines, partial, regions } +} + +/** `줄번호 | 코드` — 모델이 diff의 줄 번호와 대조할 수 있게 맞춘다 */ +function renderRegion(region: SourceRegion): string { + const width = String(region.startLine + region.lines.length - 1).length + return region.lines + .map((line, index) => `${String(region.startLine + index).padStart(width, ' ')} | ${line}`) + .join('\n') +} + +export function renderFileSource(source: FileSource): string { + const scope = source.partial + ? `전체 ${source.totalLines}줄 중 변경 구간 주변만 발췌` + : `전체 ${source.totalLines}줄` + const header = `### ${source.path} (${scope})` + + if (source.regions.length === 0) return `${header}\n(내용을 읽지 못했다)` + + const body = source.regions + .map((region) => renderRegion(region)) + .join('\n\n… (중략) …\n\n') + + return `${header}\n\`\`\`\n${body}\n\`\`\`` +} + +/** 이 파일 원본이 프롬프트에서 차지할 길이 — 청크 예산 계산에 쓴다 */ +export function sourceLength(source: FileSource): number { + return renderFileSource(source).length +} + +/** + * diff에 없는 파일을 통째로 싣는다. `read_file` 로 모델이 따로 요청한 파일용이다. + * + * 헝크가 없어 어디를 남길지 고를 수 없으므로 앞에서부터 예산만큼 자른다 — + * 대부분의 파일은 import와 주요 선언이 위쪽에 있어 앞부분이 맥락을 더 많이 담는다. + */ +export function renderPlainSource(path: string, content: string, maxChars: number): string { + const lines = content.split('\n') + const kept: string[] = [] + let used = 0 + for (const line of lines) { + if (used + line.length + 1 > maxChars) break + kept.push(line) + used += line.length + 1 + } + + const scope = + kept.length === lines.length ? `전체 ${lines.length}줄` : `전체 ${lines.length}줄 중 앞 ${kept.length}줄` + const width = String(kept.length).length + const body = kept.map((line, index) => `${String(index + 1).padStart(width, ' ')} | ${line}`).join('\n') + + return `### ${path} (${scope})\n\`\`\`\n${body}\n\`\`\`` +}