diff --git a/src/ai/predictionPairEvidenceBuilder.ts b/src/ai/predictionPairEvidenceBuilder.ts new file mode 100644 index 0000000..b3b1335 --- /dev/null +++ b/src/ai/predictionPairEvidenceBuilder.ts @@ -0,0 +1,221 @@ +import { Buffer } from "node:buffer" +import type { BranchComparisonPair } from "../branches/types.js" +import type { + GitMergeCodeContextPairResult, + GitMergeTreePairResult, + MergeCodeContextEvidence +} from "../git/types.js" +import type { BranchConflictGraphEdge } from "../risks/types.js" +import type { + AiPredictionPairCodeContext, + AiPredictionPairEvidencePayload, + AiPredictionPairMergeStatus, + AiPredictionPairTargetStatus +} from "./types.js" + +// 한 branch 조합에 포함할 수 있는 코드 원문의 UTF-8 byte 상한 +const CODE_CONTEXT_MAX_BYTES = 128 * 1024 + +// 선택된 branch 조합의 graph, merge, 코드 문맥을 하나의 AI evidence로 구성 +export function build( + edge: BranchConflictGraphEdge, + mergeResult: GitMergeTreePairResult, + codeContextResult?: GitMergeCodeContextPairResult +): AiPredictionPairEvidencePayload { + const targetStatus = targetStatusFor(edge) + + assertMatchingPair(edge.pair, mergeResult.pair) + assertMatchingCodeContext(edge.pair, codeContextResult) + + const mergeStatus = mergeStatusFor(targetStatus, mergeResult) + + return { + pair: edge.pair, + targetStatus, + reasons: edge.reasons, + branches: { + left: { + name: edge.pair.leftBranchName, + commitOid: mergeResult.leftCommitOid + }, + right: { + name: edge.pair.rightBranchName, + commitOid: mergeResult.rightCommitOid + } + }, + merge: { + status: mergeStatus, + mergedTreeOid: mergeResult.mergedTreeOid, + conflictFiles: mergeResult.conflictFiles, + conflicts: mergeResult.conflicts + }, + codeContext: codeContextFor(codeContextResult) + } +} + +// graph edge가 확정 conflict 또는 critical potential overlap 대상인지 검증 +function targetStatusFor( + edge: BranchConflictGraphEdge +): AiPredictionPairTargetStatus { + if (edge.status === "confirmed_conflict") { + return edge.status + } + + if ( + edge.status === "potential_overlap" && + edge.reasons.some(reason => reason.code === "same_hunk_overlap") + ) { + return edge.status + } + + throw new Error("AI prediction pair evidence requires selected branch pair") +} + +// graph target 상태와 실제 merge 수집 상태가 일치하는지 검증 +function mergeStatusFor( + targetStatus: AiPredictionPairTargetStatus, + mergeResult: GitMergeTreePairResult +): AiPredictionPairMergeStatus { + if (mergeResult.status === "merge_check_failed") { + throw new Error("AI prediction pair evidence requires successful merge collection") + } + + const matchesTarget = targetStatus === "confirmed_conflict" + ? mergeResult.status === "confirmed_conflict" + : mergeResult.status === "clean" + + if (!matchesTarget) { + throw new Error("AI prediction pair evidence must use matching merge status") + } + + return mergeResult.status +} + +// code context 결과와 내부 evidence가 같은 ordered pair에 속하는지 검증 +function assertMatchingCodeContext( + pair: BranchComparisonPair, + result: GitMergeCodeContextPairResult | undefined +): void { + if (!result) { + return + } + + assertMatchingPair(pair, result.pair) + + for (const evidence of result.evidence) { + assertMatchingEvidence(pair, evidence) + } +} + +// 코드 문맥 하나가 대상 ordered pair에 속하는지 검증 +function assertMatchingEvidence( + pair: BranchComparisonPair, + evidence: MergeCodeContextEvidence +): void { + assertMatchingPair(pair, evidence.pair) +} + +// 두 branch 조합의 좌우 이름과 순서가 모두 같은지 검증 +function assertMatchingPair( + pair: BranchComparisonPair, + candidate: BranchComparisonPair +): void { + if ( + pair.leftBranchName !== candidate.leftBranchName || + pair.rightBranchName !== candidate.rightBranchName + ) { + throw new Error( + "AI prediction pair evidence must use matching ordered branch pair" + ) + } +} + +// code context 수집 결과를 available, missing, failed 상태로 변환 +function codeContextFor( + result: GitMergeCodeContextPairResult | undefined +): AiPredictionPairCodeContext { + if (!result) { + return { + status: "missing", + overlapFiles: [], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + } + } + + if (result.errorMessage) { + return { + status: "failed", + overlapFiles: [], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0, + errorMessage: result.errorMessage + } + } + + const limited = limitedCodeContextFor(result.evidence) + + return { + status: "available", + overlapFiles: result.overlapFiles, + ...limited + } +} + +// evidence 순서를 유지하며 hunk의 네 version을 분리하지 않고 조합 상한 적용 +function limitedCodeContextFor(evidence: MergeCodeContextEvidence[]) { + let includedByteCount = 0 + let includedHunkCount = 0 + + for (const item of evidence) { + const byteCount = rawCodeByteCountFor(item) + + if (CODE_CONTEXT_MAX_BYTES < includedByteCount + byteCount) { + break + } + + includedByteCount += byteCount + includedHunkCount += 1 + } + + const includedEvidence = evidence.slice(0, includedHunkCount) + const omittedEvidence = evidence.slice(includedHunkCount) + + return { + evidence: includedEvidence, + includedFileCount: uniqueFileCountFor(includedEvidence), + includedHunkCount, + omittedFileCount: uniqueFileCountFor(omittedEvidence), + omittedHunkCount: omittedEvidence.length + } +} + +// 한 hunk의 base, left, right, merged 코드 원문 UTF-8 byte 합산 +function rawCodeByteCountFor(evidence: MergeCodeContextEvidence): number { + const snippets = [ + evidence.baseSnippet, + evidence.leftSnippet, + evidence.rightSnippet, + evidence.mergedSnippet + ] + + return snippets.reduce( + (byteCount, snippet) => byteCount + ( + typeof snippet.content === "string" + ? Buffer.byteLength(snippet.content, "utf8") + : 0 + ), + 0 + ) +} + +// evidence 목록에 포함된 중복 없는 file 수 계산 +function uniqueFileCountFor(evidence: MergeCodeContextEvidence[]): number { + return new Set(evidence.map(item => item.filePath)).size +} diff --git a/src/ai/predictionPairRequestBuilder.ts b/src/ai/predictionPairRequestBuilder.ts new file mode 100644 index 0000000..2dd1f19 --- /dev/null +++ b/src/ai/predictionPairRequestBuilder.ts @@ -0,0 +1,74 @@ +import type { BranchComparisonPair } from "../branches/types.js" +import type { + GitMergeCodeContextPairResult, + GitMergeTreePairResult +} from "../git/types.js" +import type { BranchConflictGraphEdge } from "../risks/types.js" +import { build as buildEvidence } from "./predictionPairEvidenceBuilder.js" +import { select } from "./predictionPairTargetSelector.js" +import type { AiPredictionPairEvidencePayload } from "./types.js" + +// graph, merge, 코드 문맥 결과를 선택된 branch 조합별 AI 요청 입력으로 조립 +export function build( + edges: BranchConflictGraphEdge[], + mergeResults: GitMergeTreePairResult[], + codeContextResults: GitMergeCodeContextPairResult[] = [] +): AiPredictionPairEvidencePayload[] { + const targets = select(edges) + const mergeIndex = indexByPair( + mergeResults, + "AI prediction pair request requires unique merge result" + ) + const contextIndex = indexByPair( + codeContextResults, + "AI prediction pair request requires unique code context result" + ) + const seenKeys = new Set() + + return targets.map(edge => { + const key = pairKeyFor(edge.pair) + + if (seenKeys.has(key)) { + throw new Error( + "AI prediction pair request requires unique selected branch pair" + ) + } + + seenKeys.add(key) + + const merge = mergeIndex.get(key) + + if (!merge) { + throw new Error( + "AI prediction pair request requires merge result for selected branch pair" + ) + } + + return buildEvidence(edge, merge, contextIndex.get(key)) + }) +} + +// ordered pair별 결과를 색인하고 같은 조합의 중복 결과 거부 +function indexByPair( + results: T[], + duplicateErrorMessage: string +): Map { + const index = new Map() + + for (const result of results) { + const key = pairKeyFor(result.pair) + + if (index.has(key)) { + throw new Error(duplicateErrorMessage) + } + + index.set(key, result) + } + + return index +} + +// 좌우 branch 순서를 보존하는 충돌 없는 조합 key 구성 +function pairKeyFor(pair: BranchComparisonPair): string { + return `${pair.leftBranchName}\u0000${pair.rightBranchName}` +} diff --git a/src/ai/predictionPairTargetSelector.ts b/src/ai/predictionPairTargetSelector.ts new file mode 100644 index 0000000..07724a9 --- /dev/null +++ b/src/ai/predictionPairTargetSelector.ts @@ -0,0 +1,17 @@ +import type { BranchConflictGraphEdge } from "../risks/types.js" + +// 확정 conflict와 critical potential overlap 조합만 AI 분석 대상으로 선별 +export function select( + edges: BranchConflictGraphEdge[] +): BranchConflictGraphEdge[] { + return edges.filter(isTarget) +} + +function isTarget(edge: BranchConflictGraphEdge): boolean { + if (edge.status === "confirmed_conflict") { + return true + } + + return edge.status === "potential_overlap" && + edge.reasons.some(reason => reason.code === "same_hunk_overlap") +} diff --git a/src/ai/types.ts b/src/ai/types.ts index c40098d..705de17 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -1,6 +1,19 @@ -import type { BranchContext } from "../branches/types.js" -import type { GitMergeSignal } from "../git/types.js" -import type { BranchChangedHunk, BranchRisk } from "../risks/types.js" +import type { + BranchComparisonPair, + BranchContext +} from "../branches/types.js" +import type { + GitMergeSignal, + GitMergeSignalStatus, + GitMergeTreeConflict, + MergeCodeContextEvidence +} from "../git/types.js" +import type { + BranchChangedHunk, + BranchConflictGraphEdgeReason, + BranchConflictGraphEdgeStatus, + BranchRisk +} from "../risks/types.js" // AI prediction 생성에 사용할 deterministic 분석 근거 묶음 export type AiPredictionEvidencePayload = { @@ -10,6 +23,62 @@ export type AiPredictionEvidencePayload = { changedHunks: BranchChangedHunk[] } +// AI가 분석할 수 있는 확정 conflict와 critical potential overlap 상태 +export type AiPredictionPairTargetStatus = Extract< + BranchConflictGraphEdgeStatus, + "confirmed_conflict" | "potential_overlap" +> + +// AI evidence에 포함할 수 있는 성공한 merge 수집 상태 +export type AiPredictionPairMergeStatus = Exclude< + GitMergeSignalStatus, + "merge_check_failed" +> + +// branch 조합의 한쪽 branch 이름과 수집된 commit OID +export type AiPredictionPairBranchMetadata = { + name: string + commitOid?: string +} + +// branch 조합 코드 문맥의 수집 결과 상태 +export type AiPredictionPairCodeContextStatus = + | "available" + | "missing" + | "failed" + +// branch 조합에서 겹친 파일과 수집된 코드 문맥 또는 실패 정보 +export type AiPredictionPairCodeContext = { + status: AiPredictionPairCodeContextStatus + overlapFiles: string[] + evidence: MergeCodeContextEvidence[] + // 조합 상한 안에 포함된 file과 hunk 수 + includedFileCount: number + includedHunkCount: number + // 조합 상한으로 누락된 file과 hunk 수 + omittedFileCount: number + omittedHunkCount: number + errorMessage?: string +} + +// 위험한 branch 조합 하나에 속한 deterministic 상태와 코드 문맥 묶음 +export type AiPredictionPairEvidencePayload = { + pair: BranchComparisonPair + targetStatus: AiPredictionPairTargetStatus + reasons: BranchConflictGraphEdgeReason[] + branches: { + left: AiPredictionPairBranchMetadata + right: AiPredictionPairBranchMetadata + } + merge: { + status: AiPredictionPairMergeStatus + mergedTreeOid?: string + conflictFiles: string[] + conflicts: GitMergeTreeConflict[] + } + codeContext: AiPredictionPairCodeContext +} + // AI provider에 전달할 system/user prompt 묶음 export type AiPredictionPrompt = { systemPrompt: string diff --git a/tests/ai/predictionPairEvidenceBuilder.test.ts b/tests/ai/predictionPairEvidenceBuilder.test.ts new file mode 100644 index 0000000..55053e1 --- /dev/null +++ b/tests/ai/predictionPairEvidenceBuilder.test.ts @@ -0,0 +1,323 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { build } from "../../src/ai/predictionPairEvidenceBuilder.js" +import type { BranchComparisonPair } from "../../src/branches/types.js" +import type { + GitMergeCodeContextPairResult, + GitMergeTreePairResult, + MergeCodeContextEvidence, + MergeCodeContextKind +} from "../../src/git/types.js" +import type { + BranchConflictGraphEdge, + BranchConflictGraphEdgeReasonCode +} from "../../src/risks/types.js" + +// 확정 conflict 조합의 merge 상태와 네 version 코드 문맥을 evidence로 구성하는지 확인 +test("builds confirmed conflict pair evidence", () => { + const pair = comparisonPair() + const edge = graphEdge(pair, "confirmed_conflict", ["confirmed_conflict"]) + const context = codeEvidence(pair, "confirmed_conflict") + const payload = build( + edge, + mergeResult(pair, "confirmed_conflict"), + codeContextResult(pair, context) + ) + + assert.deepEqual(payload.pair, pair) + assert.equal(payload.targetStatus, "confirmed_conflict") + assert.deepEqual(payload.reasons, edge.reasons) + assert.deepEqual(payload.branches, { + left: { + name: "feature/left", + commitOid: "left-oid" + }, + right: { + name: "feature/right", + commitOid: "right-oid" + } + }) + assert.deepEqual(payload.merge, { + status: "confirmed_conflict", + mergedTreeOid: "merged-tree-oid", + conflictFiles: ["src/value.ts"], + conflicts: [{ + paths: ["src/value.ts"], + type: "content" + }] + }) + assert.equal(payload.codeContext.status, "available") + assert.deepEqual(payload.codeContext.overlapFiles, ["src/value.ts"]) + assert.deepEqual(payload.codeContext.evidence, [context]) + assert.equal(payload.codeContext.includedFileCount, 1) + assert.equal(payload.codeContext.includedHunkCount, 1) + assert.equal(payload.codeContext.omittedFileCount, 0) + assert.equal(payload.codeContext.omittedHunkCount, 0) +}) + +// 같은 hunk가 겹친 clean merge 조합을 potential overlap evidence로 구분하는지 확인 +test("builds potential overlap pair evidence", () => { + const pair = comparisonPair() + const context = codeEvidence(pair, "clean_hunk_overlap") + const payload = build( + graphEdge(pair, "potential_overlap", [ + "same_hunk_overlap", + "same_file_overlap" + ]), + mergeResult(pair, "clean"), + codeContextResult(pair, context) + ) + + assert.equal(payload.targetStatus, "potential_overlap") + assert.equal(payload.merge.status, "clean") + assert.deepEqual(payload.merge.conflictFiles, []) + assert.equal(payload.codeContext.evidence[0]?.kind, "clean_hunk_overlap") + assert.equal(payload.codeContext.includedFileCount, 1) + assert.equal(payload.codeContext.includedHunkCount, 1) + assert.equal(payload.codeContext.omittedFileCount, 0) + assert.equal(payload.codeContext.omittedHunkCount, 0) +}) + +// 조합 상한을 UTF-8 byte로 적용하고 hunk의 네 version을 함께 포함하거나 누락하는지 확인 +test("limits pair code context without splitting hunk versions", () => { + const pair = comparisonPair() + const included = codeEvidence( + pair, + "clean_hunk_overlap", + "src/included.ts", + "가".repeat(10922) + "ab", + true + ) + const omitted = codeEvidence( + pair, + "clean_hunk_overlap", + "src/omitted.ts", + "b" + ) + const payload = build( + graphEdge(pair, "potential_overlap", [ + "same_hunk_overlap", + "same_file_overlap" + ]), + mergeResult(pair, "clean"), + codeContextResult(pair, [included, omitted, omitted]) + ) + + assert.deepEqual(payload.codeContext.evidence, [included]) + assert.equal(payload.codeContext.includedFileCount, 1) + assert.equal(payload.codeContext.includedHunkCount, 1) + assert.equal(payload.codeContext.omittedFileCount, 1) + assert.equal(payload.codeContext.omittedHunkCount, 2) + assert.equal(payload.codeContext.evidence[0]?.baseSnippet.truncated, true) +}) + +// 확정 conflict의 코드 문맥이 없거나 실패해도 조합 evidence 자체는 유지하는지 확인 +test("keeps confirmed conflict evidence when code context is unavailable", () => { + const pair = comparisonPair() + const edge = graphEdge(pair, "confirmed_conflict", ["confirmed_conflict"]) + const merge = mergeResult(pair, "confirmed_conflict") + const missing = build(edge, merge) + const failed = build(edge, merge, { + pair, + overlapFiles: [], + evidence: [], + errorMessage: "code context failed" + }) + + assert.deepEqual(missing.codeContext, { + status: "missing", + overlapFiles: [], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + }) + assert.deepEqual(failed.codeContext, { + status: "failed", + overlapFiles: [], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0, + errorMessage: "code context failed" + }) +}) + +// edge와 다른 좌우 순서나 branch 조합의 merge/code evidence 혼입을 거부하는지 확인 +test("rejects mismatched ordered branch pair evidence", () => { + const pair = comparisonPair() + const edge = graphEdge(pair, "confirmed_conflict", ["confirmed_conflict"]) + const merge = mergeResult(pair, "confirmed_conflict") + const reversed = comparisonPair("feature/right", "feature/left") + const other = comparisonPair("feature/left", "feature/other") + + assert.throws( + () => build(edge, mergeResult(reversed, "confirmed_conflict")), + /matching ordered branch pair/ + ) + assert.throws( + () => build(edge, merge, codeContextResult(other, codeEvidence(other))), + /matching ordered branch pair/ + ) + assert.throws( + () => build(edge, merge, { + pair, + overlapFiles: ["src/value.ts"], + evidence: [codeEvidence(other)] + }), + /matching ordered branch pair/ + ) +}) + +// 선택 대상이 아닌 edge와 edge 상태에 맞지 않는 merge 결과를 거부하는지 확인 +test("rejects unselected edges and mismatched merge status", () => { + const pair = comparisonPair() + + assert.throws( + () => build( + graphEdge(pair, "clean", ["clean_merge"]), + mergeResult(pair, "clean") + ), + /requires selected branch pair/ + ) + assert.throws( + () => build( + graphEdge(pair, "potential_overlap", ["same_file_overlap"]), + mergeResult(pair, "clean") + ), + /requires selected branch pair/ + ) + assert.throws( + () => build( + graphEdge(pair, "confirmed_conflict", ["confirmed_conflict"]), + mergeResult(pair, "clean") + ), + /matching merge status/ + ) +}) + +// 테스트용 ordered branch 조합 구성 +function comparisonPair( + leftBranchName = "feature/left", + rightBranchName = "feature/right" +): BranchComparisonPair { + return { + leftBranchName, + rightBranchName + } +} + +// 테스트할 graph edge와 reason 구성 +function graphEdge( + pair: BranchComparisonPair, + status: BranchConflictGraphEdge["status"], + reasonCodes: BranchConflictGraphEdgeReasonCode[] +): BranchConflictGraphEdge { + return { + pair, + status, + reasons: reasonCodes.map(code => ({ + code, + files: code === "clean_merge" ? undefined : ["src/value.ts"] + })) + } +} + +// 테스트할 clean 또는 confirmed conflict merge 결과 구성 +function mergeResult( + pair: BranchComparisonPair, + status: "clean" | "confirmed_conflict" +): GitMergeTreePairResult { + const confirmed = status === "confirmed_conflict" + + return { + pair, + status, + leftCommitOid: "left-oid", + rightCommitOid: "right-oid", + mergedTreeOid: "merged-tree-oid", + conflictFiles: confirmed ? ["src/value.ts"] : [], + conflicts: confirmed + ? [{ + paths: ["src/value.ts"], + type: "content" + }] + : [] + } +} + +// 코드 문맥 하나를 포함하는 테스트용 조합 결과 구성 +function codeContextResult( + pair: BranchComparisonPair, + evidence: MergeCodeContextEvidence | MergeCodeContextEvidence[] +): GitMergeCodeContextPairResult { + const items = Array.isArray(evidence) ? evidence : [evidence] + + return { + pair, + overlapFiles: [...new Set(items.map(item => item.filePath))], + evidence: items + } +} + +// 네 version의 commit metadata와 snippet을 포함하는 테스트용 evidence 구성 +function codeEvidence( + pair: BranchComparisonPair, + kind: MergeCodeContextKind = "confirmed_conflict", + filePath = "src/value.ts", + content?: string, + truncated = false +): MergeCodeContextEvidence { + return { + pair, + kind, + filePath, + mergeBaseOid: "base-oid", + mergedTreeOid: "merged-tree-oid", + baseCommit: { + role: "base", + oid: "base-oid", + author: "opfic", + committedAt: "2026-07-17T00:00:00.000Z", + subject: "base" + }, + leftCommit: { + role: "left", + branchName: pair.leftBranchName, + oid: "left-oid", + author: "opfic", + committedAt: "2026-07-17T00:01:00.000Z", + subject: "left" + }, + rightCommit: { + role: "right", + branchName: pair.rightBranchName, + oid: "right-oid", + author: "opfic", + committedAt: "2026-07-17T00:02:00.000Z", + subject: "right" + }, + baseSnippet: snippet(content ?? "base", filePath, truncated), + leftSnippet: snippet(content ?? "left", filePath, truncated), + rightSnippet: snippet(content ?? "right", filePath, truncated), + mergedSnippet: snippet(content ?? "merged", filePath, truncated) + } +} + +// 한 줄 text file을 표현하는 테스트용 snippet 구성 +function snippet( + content: string, + filePath = "src/value.ts", + truncated = false +) { + return { + status: "text" as const, + filePath, + content, + startLine: 1, + endLine: 1, + truncated + } +} diff --git a/tests/ai/predictionPairRequestBuilder.test.ts b/tests/ai/predictionPairRequestBuilder.test.ts new file mode 100644 index 0000000..c3e0685 --- /dev/null +++ b/tests/ai/predictionPairRequestBuilder.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { BranchComparisonPair } from "../../src/branches/types.js" +import type { + GitMergeCodeContextPairResult, + GitMergeTreePairResult, + MergeCodeContextEvidence, + MergeCodeContextKind +} from "../../src/git/types.js" +import type { + BranchConflictGraphEdge, + BranchConflictGraphEdgeReasonCode, + BranchConflictGraphEdgeStatus +} from "../../src/risks/types.js" +import { build } from "../../src/ai/predictionPairRequestBuilder.js" + +// 위험한 조합만 기존 순서대로 하나의 요청 입력씩 구성하는지 확인 +test("builds one request payload per selected branch pair", () => { + const confirmedPair = comparisonPair("feature/confirmed-left", "feature/confirmed-right") + const overlapPair = comparisonPair("feature/overlap-left", "feature/overlap-right") + const sameFilePair = comparisonPair("feature/file-left", "feature/file-right") + const cleanPair = comparisonPair("feature/clean-left", "feature/clean-right") + const errorPair = comparisonPair("feature/error-left", "feature/error-right") + const edges = [ + graphEdge(confirmedPair, "confirmed_conflict", ["confirmed_conflict"]), + graphEdge(cleanPair, "clean", ["clean_merge"]), + graphEdge(overlapPair, "potential_overlap", [ + "same_hunk_overlap", + "same_file_overlap" + ]), + graphEdge(errorPair, "error", ["merge_check_failed"]), + graphEdge(sameFilePair, "potential_overlap", ["same_file_overlap"]) + ] + const payloads = build( + edges, + [ + mergeResult(overlapPair, "clean"), + mergeResult(confirmedPair, "confirmed_conflict") + ], + [ + codeContextResult(overlapPair, "src/overlap.ts", "clean_hunk_overlap"), + codeContextResult(confirmedPair, "src/confirmed.ts") + ] + ) + + assert.deepEqual(payloads.map(payload => payload.pair), [ + confirmedPair, + overlapPair + ]) + assert.deepEqual(payloads.map(payload => payload.targetStatus), [ + "confirmed_conflict", + "potential_overlap" + ]) + assert.deepEqual( + payloads.map(payload => payload.codeContext.evidence[0]?.filePath), + ["src/confirmed.ts", "src/overlap.ts"] + ) +}) + +// 코드 문맥 결과 순서와 관계없이 ordered pair별 evidence가 격리되는지 확인 +test("isolates code context for each ordered branch pair", () => { + const firstPair = comparisonPair("feature/first-left", "feature/first-right") + const secondPair = comparisonPair("feature/second-left", "feature/second-right") + const payloads = build( + [ + graphEdge(firstPair, "confirmed_conflict", ["confirmed_conflict"]), + graphEdge(secondPair, "confirmed_conflict", ["confirmed_conflict"]) + ], + [ + mergeResult(firstPair, "confirmed_conflict"), + mergeResult(secondPair, "confirmed_conflict") + ], + [ + codeContextResult(secondPair, "src/second.ts"), + codeContextResult(firstPair, "src/first.ts") + ] + ) + + assert.equal(payloads[0]?.codeContext.evidence[0]?.filePath, "src/first.ts") + assert.equal(payloads[1]?.codeContext.evidence[0]?.filePath, "src/second.ts") +}) + +// 코드 문맥 결과가 없어도 confirmed conflict 요청 입력을 유지하는지 확인 +test("keeps confirmed conflict payload when code context is missing", () => { + const pair = comparisonPair() + const payloads = build( + [graphEdge(pair, "confirmed_conflict", ["confirmed_conflict"])], + [mergeResult(pair, "confirmed_conflict")] + ) + + assert.equal(payloads.length, 1) + assert.equal(payloads[0]?.codeContext.status, "missing") +}) + +// 선택된 조합의 merge 누락과 edge, merge, 코드 문맥 중복을 거부하는지 확인 +test("rejects missing and duplicate ordered pair inputs", () => { + const pair = comparisonPair() + const edge = graphEdge(pair, "confirmed_conflict", ["confirmed_conflict"]) + const merge = mergeResult(pair, "confirmed_conflict") + const context = codeContextResult(pair, "src/value.ts") + + assert.throws( + () => build([edge], []), + /requires merge result for selected branch pair/ + ) + assert.throws( + () => build([edge, edge], [merge]), + /requires unique selected branch pair/ + ) + assert.throws( + () => build([edge], [merge, merge]), + /requires unique merge result/ + ) + assert.throws( + () => build([edge], [merge], [context, context]), + /requires unique code context result/ + ) +}) + +// 테스트용 ordered branch 조합 구성 +function comparisonPair( + leftBranchName = "feature/left", + rightBranchName = "feature/right" +): BranchComparisonPair { + return { + leftBranchName, + rightBranchName + } +} + +// 상태와 reason을 포함하는 테스트용 graph edge 구성 +function graphEdge( + pair: BranchComparisonPair, + status: BranchConflictGraphEdgeStatus, + reasonCodes: BranchConflictGraphEdgeReasonCode[] +): BranchConflictGraphEdge { + return { + pair, + status, + reasons: reasonCodes.map(code => ({ code })), + ...(status === "error" ? { errorMessage: "merge failed" } : {}) + } +} + +// 선택된 조합의 테스트용 merge 결과 구성 +function mergeResult( + pair: BranchComparisonPair, + status: "clean" | "confirmed_conflict" +): GitMergeTreePairResult { + return { + pair, + status, + leftCommitOid: `${pair.leftBranchName}-oid`, + rightCommitOid: `${pair.rightBranchName}-oid`, + mergedTreeOid: "merged-tree-oid", + conflictFiles: status === "confirmed_conflict" ? ["src/value.ts"] : [], + conflicts: status === "confirmed_conflict" + ? [{ paths: ["src/value.ts"], type: "content" }] + : [] + } +} + +// 한 조합의 파일과 코드 문맥을 포함하는 테스트용 결과 구성 +function codeContextResult( + pair: BranchComparisonPair, + filePath: string, + kind: MergeCodeContextKind = "confirmed_conflict" +): GitMergeCodeContextPairResult { + return { + pair, + overlapFiles: [filePath], + evidence: [codeEvidence(pair, filePath, kind)] + } +} + +// 네 version snippet을 포함하는 테스트용 코드 evidence 구성 +function codeEvidence( + pair: BranchComparisonPair, + filePath: string, + kind: MergeCodeContextKind +): MergeCodeContextEvidence { + return { + pair, + kind, + filePath, + mergeBaseOid: "base-oid", + mergedTreeOid: "merged-tree-oid", + baseCommit: { + role: "base", + oid: "base-oid", + author: "opfic", + committedAt: "2026-07-17T00:00:00.000Z", + subject: "base" + }, + leftCommit: { + role: "left", + branchName: pair.leftBranchName, + oid: "left-oid", + author: "opfic", + committedAt: "2026-07-17T00:01:00.000Z", + subject: "left" + }, + rightCommit: { + role: "right", + branchName: pair.rightBranchName, + oid: "right-oid", + author: "opfic", + committedAt: "2026-07-17T00:02:00.000Z", + subject: "right" + }, + baseSnippet: snippet(filePath, "base"), + leftSnippet: snippet(filePath, "left"), + rightSnippet: snippet(filePath, "right"), + mergedSnippet: snippet(filePath, "merged") + } +} + +// 한 줄 text file을 표현하는 테스트용 snippet 구성 +function snippet(filePath: string, content: string) { + return { + status: "text" as const, + filePath, + content, + startLine: 1, + endLine: 1, + truncated: false + } +} diff --git a/tests/ai/predictionPairTargetSelector.test.ts b/tests/ai/predictionPairTargetSelector.test.ts new file mode 100644 index 0000000..eac55a0 --- /dev/null +++ b/tests/ai/predictionPairTargetSelector.test.ts @@ -0,0 +1,88 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { select } from "../../src/ai/predictionPairTargetSelector.js" +import type { + BranchConflictGraphEdge, + BranchConflictGraphEdgeReasonCode, + BranchConflictGraphEdgeStatus +} from "../../src/risks/types.js" + +// 확정 conflict 조합은 다른 signal과 관계없이 AI 분석 대상에 포함하는지 확인 +test("includes confirmed conflict pairs", () => { + const target = edge( + "feature/conflict-a", + "feature/conflict-b", + "confirmed_conflict", + ["confirmed_conflict"] + ) + + assert.deepEqual(select([target]), [target]) +}) + +// 같은 hunk가 겹치는 potential overlap 조합을 critical 대상으로 포함하는지 확인 +test("includes critical potential overlap pairs", () => { + const target = edge( + "feature/overlap-a", + "feature/overlap-b", + "potential_overlap", + ["same_hunk_overlap", "same_file_overlap"] + ) + + assert.deepEqual(select([target]), [target]) +}) + +// 같은 파일만 겹치는 potential overlap 조합은 AI 분석 대상에서 제외하는지 확인 +test("excludes same-file-only potential overlap pairs", () => { + const candidate = edge( + "feature/file-a", + "feature/file-b", + "potential_overlap", + ["same_file_overlap"] + ) + + assert.deepEqual(select([candidate]), []) +}) + +// 제외 대상을 제거하면서 기존 graph edge 순서를 유지하는지 확인 +test("excludes clean and error pairs while preserving target order", () => { + const first = edge( + "feature/first-a", + "feature/first-b", + "confirmed_conflict", + ["confirmed_conflict"] + ) + const second = edge( + "feature/second-a", + "feature/second-b", + "potential_overlap", + ["same_hunk_overlap", "same_file_overlap"] + ) + + const selected = select([ + edge("feature/clean-a", "feature/clean-b", "clean", ["clean_merge"]), + first, + edge("feature/error-a", "feature/error-b", "error", ["merge_check_failed"]), + second + ]) + + assert.deepEqual(selected, [first, second]) +}) + +function edge( + leftBranchName: string, + rightBranchName: string, + status: BranchConflictGraphEdgeStatus, + reasonCodes: BranchConflictGraphEdgeReasonCode[] +): BranchConflictGraphEdge { + return { + pair: { + leftBranchName, + rightBranchName + }, + status, + reasons: reasonCodes.map(code => ({ code })), + ...(status === "error" + ? { errorMessage: "branch pair collection failed" } + : {}) + } +}