From 8da0c04e33e2efa69ddd9a77dce92ef97c279b83 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:24:22 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=84=20AI=20prompt=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionPairPromptBuilder.ts | 27 ++++++ src/ai/predictionPairPromptTemplates.ts | 74 +++++++++++++++++ src/ai/types.ts | 71 ++++++++++++++++ tests/ai/predictionPairPromptBuilder.test.ts | 87 ++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 src/ai/predictionPairPromptBuilder.ts create mode 100644 src/ai/predictionPairPromptTemplates.ts create mode 100644 tests/ai/predictionPairPromptBuilder.test.ts diff --git a/src/ai/predictionPairPromptBuilder.ts b/src/ai/predictionPairPromptBuilder.ts new file mode 100644 index 0000000..0901310 --- /dev/null +++ b/src/ai/predictionPairPromptBuilder.ts @@ -0,0 +1,27 @@ +import { + DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT, + DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT +} from "./predictionPairPromptTemplates.js" +import type { + AiPredictionPairEvidencePayload, + AiPredictionPrompt, + AiPredictionPromptBuildOptions +} from "./types.js" + +// branch 조합의 deterministic 상태에 맞는 전용 prompt 구성 +export function build( + payload: AiPredictionPairEvidencePayload, + options: AiPredictionPromptBuildOptions = {} +): AiPredictionPrompt { + const confirmedConflict = payload.targetStatus === "confirmed_conflict" + + return { + systemPrompt: options.systemPrompt ?? (confirmedConflict + ? DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT + : DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT), + userPrompt: JSON.stringify(payload, null, 2), + responseShape: confirmedConflict + ? "predictionPairConfirmedConflict" + : "predictionPairCleanOverlap" + } +} diff --git a/src/ai/predictionPairPromptTemplates.ts b/src/ai/predictionPairPromptTemplates.ts new file mode 100644 index 0000000..0a4d3a9 --- /dev/null +++ b/src/ai/predictionPairPromptTemplates.ts @@ -0,0 +1,74 @@ +// 확정 conflict의 원인, 반영 순서, 구체적인 patch를 요청하는 system prompt +export const DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT = [ + "You are Watcher's confirmed merge conflict resolution assistant.", + "Use only the provided branch pair evidence and code context.", + "Do not recalculate or overwrite the deterministic merge status or graph reasons.", + "Explain the confirmed conflict cause from the provided code only.", + "Recommend a merge or rebase order using only the two provided branch names.", + "Provide at least one concrete patch for files present in the evidence.", + "Do not invent APIs, files, behavior, or code outside the provided context.", + "Do not apply any patch or describe a patch as already applied.", + "Write summaries, reasons, steps, and patch reasons in Korean.", + "Return only JSON with this shape:", + "{", + " \"kind\": \"confirmed_conflict\",", + " \"pair\": {", + " \"leftBranchName\": string,", + " \"rightBranchName\": string", + " },", + " \"conflictCause\": {", + " \"summary\": string,", + " \"files\": string[]", + " },", + " \"integrationOrder\": {", + " \"strategy\": \"merge\" | \"rebase\",", + " \"firstBranchName\": string,", + " \"secondBranchName\": string,", + " \"reason\": string,", + " \"steps\": string[]", + " },", + " \"patches\": [{", + " \"filePath\": string,", + " \"patch\": string,", + " \"reason\": string", + " }]", + "}" +].join("\n") + +// clean overlap을 conflict로 단정하지 않고 예방 조치를 요청하는 system prompt +export const DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT = [ + "You are Watcher's clean merge overlap prevention assistant.", + "Use only the provided branch pair evidence and code context.", + "The provided merge status is clean and is not a confirmed conflict.", + "Do not recalculate or overwrite the deterministic merge status or graph reasons.", + "Do not claim that a conflict exists or will occur.", + "Explain only the potential interaction visible in the provided code.", + "Recommend a merge or rebase order using only the two provided branch names.", + "Provide at least one preventive action and do not provide patches.", + "Do not invent APIs, files, behavior, or code outside the provided context.", + "Write summaries, reasons, steps, titles, and descriptions in Korean.", + "Return only JSON with this shape:", + "{", + " \"kind\": \"clean_overlap\",", + " \"pair\": {", + " \"leftBranchName\": string,", + " \"rightBranchName\": string", + " },", + " \"overlapCause\": {", + " \"summary\": string,", + " \"files\": string[]", + " },", + " \"integrationOrder\": {", + " \"strategy\": \"merge\" | \"rebase\",", + " \"firstBranchName\": string,", + " \"secondBranchName\": string,", + " \"reason\": string,", + " \"steps\": string[]", + " },", + " \"preventiveActions\": [{", + " \"title\": string,", + " \"description\": string,", + " \"files\": string[]", + " }]", + "}" +].join("\n") diff --git a/src/ai/types.ts b/src/ai/types.ts index 705de17..6309ba2 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -79,6 +79,75 @@ export type AiPredictionPairEvidencePayload = { codeContext: AiPredictionPairCodeContext } +// branch 조합을 반영할 merge 또는 rebase 작업 순서 +export type AiPredictionPairIntegrationOrder = { + strategy: "merge" | "rebase" + firstBranchName: string + secondBranchName: string + reason: string + steps: string[] +} + +// 확정 conflict를 해결하기 위한 파일별 patch 제안 +export type AiPredictionPairPatch = { + filePath: string + patch: string + reason: string +} + +// clean overlap 이후 동작 회귀를 예방하기 위한 확인 항목 +export type AiPredictionPairPreventiveAction = { + title: string + description: string + files: string[] +} + +// 확정 conflict 원인과 구체적인 해결 patch +export type AiConfirmedConflictResponse = { + kind: "confirmed_conflict" + pair: BranchComparisonPair + conflictCause: { + summary: string + files: string[] + } + integrationOrder: AiPredictionPairIntegrationOrder + patches: AiPredictionPairPatch[] +} + +// 현재 merge 가능한 overlap의 예방 조치와 반영 순서 +export type AiCleanOverlapResponse = { + kind: "clean_overlap" + pair: BranchComparisonPair + overlapCause: { + summary: string + files: string[] + } + integrationOrder: AiPredictionPairIntegrationOrder + preventiveActions: AiPredictionPairPreventiveAction[] +} + +// branch 조합의 deterministic merge 상태에 맞는 AI 응답 +export type AiPredictionPairResponse = + | AiConfirmedConflictResponse + | AiCleanOverlapResponse + +// branch 조합 하나에 대한 AI 실행 결과 +export type AiPredictionPairResult = + | AiPredictionPairPredictedResult + | AiPredictionPairFailedResult + +export type AiPredictionPairPredictedResult = { + status: "predicted" + pair: BranchComparisonPair + response: AiPredictionPairResponse +} + +export type AiPredictionPairFailedResult = { + status: "failed" + pair: BranchComparisonPair + errorMessage: string +} + // AI provider에 전달할 system/user prompt 묶음 export type AiPredictionPrompt = { systemPrompt: string @@ -90,6 +159,8 @@ export type AiPredictionPrompt = { export type AiPredictionPromptResponseShape = | "prediction" | "predictionBatch" + | "predictionPairConfirmedConflict" + | "predictionPairCleanOverlap" // prompt 호출자가 provider나 실행 환경에 맞게 system prompt를 교체하기 위한 설정 export type AiPredictionPromptBuildOptions = { diff --git a/tests/ai/predictionPairPromptBuilder.test.ts b/tests/ai/predictionPairPromptBuilder.test.ts new file mode 100644 index 0000000..231a9b9 --- /dev/null +++ b/tests/ai/predictionPairPromptBuilder.test.ts @@ -0,0 +1,87 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { build } from "../../src/ai/predictionPairPromptBuilder.js" +import { + DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT, + DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT +} from "../../src/ai/predictionPairPromptTemplates.js" +import type { + AiPredictionPairEvidencePayload, + AiPredictionPairTargetStatus +} from "../../src/ai/types.js" + +// 확정 conflict evidence를 patch 전용 prompt로 구성 +test("builds confirmed conflict pair prompt", () => { + const input = payload("confirmed_conflict") + const prompt = build(input) + + assert.equal(prompt.systemPrompt, DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT) + assert.equal(prompt.responseShape, "predictionPairConfirmedConflict") + assert.deepEqual(JSON.parse(prompt.userPrompt), input) +}) + +// clean overlap evidence를 예방 조치 전용 prompt로 구성 +test("builds clean overlap pair prompt", () => { + const input = payload("potential_overlap") + const prompt = build(input) + + assert.equal(prompt.systemPrompt, DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT) + assert.equal(prompt.responseShape, "predictionPairCleanOverlap") + assert.match(prompt.systemPrompt, /not a confirmed conflict/) + assert.deepEqual(JSON.parse(prompt.userPrompt), input) +}) + +// 호출자가 상태별 기본 prompt를 교체할 수 있도록 기존 option 유지 +test("uses caller provided pair system prompt", () => { + const prompt = build(payload("confirmed_conflict"), { + systemPrompt: "Return compact JSON." + }) + + assert.equal(prompt.systemPrompt, "Return compact JSON.") + assert.equal(prompt.responseShape, "predictionPairConfirmedConflict") +}) + +function payload( + targetStatus: AiPredictionPairTargetStatus +): AiPredictionPairEvidencePayload { + const confirmedConflict = targetStatus === "confirmed_conflict" + + return { + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + targetStatus, + reasons: [{ + code: confirmedConflict ? "confirmed_conflict" : "same_hunk_overlap", + files: ["src/shared.ts"] + }], + branches: { + left: { + name: "feature/a", + commitOid: "a".repeat(40) + }, + right: { + name: "feature/b", + commitOid: "b".repeat(40) + } + }, + merge: { + status: confirmedConflict ? "confirmed_conflict" : "clean", + mergedTreeOid: "c".repeat(40), + conflictFiles: confirmedConflict ? ["src/shared.ts"] : [], + conflicts: confirmedConflict + ? [{ paths: ["src/shared.ts"], type: "content" }] + : [] + }, + codeContext: { + status: "available", + overlapFiles: ["src/shared.ts"], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + } + } +} From 553409d9f7f0ece4b1be6d675e3808fae7439e62 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:25:21 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=EB=B3=84=20OpenAI=20=EC=9D=91=EB=8B=B5=20schema=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/openAiPredictionClient.ts | 191 +++++++++++++++++++++++- tests/ai/openAiPredictionClient.test.ts | 74 +++++++++ 2 files changed, 258 insertions(+), 7 deletions(-) diff --git a/src/ai/openAiPredictionClient.ts b/src/ai/openAiPredictionClient.ts index 471705b..084ea66 100644 --- a/src/ai/openAiPredictionClient.ts +++ b/src/ai/openAiPredictionClient.ts @@ -1,6 +1,7 @@ import type { AiPredictionClient, - AiPredictionPrompt + AiPredictionPrompt, + AiPredictionPromptResponseShape } from "./types.js" // workflow log가 과도하게 커지지 않도록 OpenAI 실패 응답 본문을 제한 @@ -96,6 +97,7 @@ function openAiRequestBodyFor( model: string ): Record { const responseShape = prompt.responseShape ?? "prediction" + const responseFormat = responseFormatFor(responseShape) return { model, @@ -112,18 +114,45 @@ function openAiRequestBodyFor( text: { format: { type: "json_schema", - name: responseShape === "predictionBatch" - ? "ai_prediction_batch" - : "ai_prediction", + name: responseFormat.name, strict: true, - schema: responseShape === "predictionBatch" - ? aiPredictionBatchSchema() - : aiPredictionSchema() + schema: responseFormat.schema } } } } +// prompt가 요청한 응답 형태를 OpenAI schema 이름과 구조에 매핑 +function responseFormatFor( + responseShape: AiPredictionPromptResponseShape +): { + name: string + schema: Record +} { + switch (responseShape) { + case "predictionBatch": + return { + name: "ai_prediction_batch", + schema: aiPredictionBatchSchema() + } + case "predictionPairConfirmedConflict": + return { + name: "ai_prediction_pair_confirmed_conflict", + schema: aiConfirmedConflictSchema() + } + case "predictionPairCleanOverlap": + return { + name: "ai_prediction_pair_clean_overlap", + schema: aiCleanOverlapSchema() + } + default: + return { + name: "ai_prediction", + schema: aiPredictionSchema() + } + } +} + // OpenAI API가 반환한 HTTP 실패 원문을 보존해 workflow log에서 원인을 확인 async function openAiRequestErrorMessageFor(response: Response): Promise { const detail = await openAiErrorDetailFor(response) @@ -222,6 +251,154 @@ function aiPredictionSchema(): Record { } } +// 확정 conflict 해결 응답을 위한 OpenAI structured output schema +function aiConfirmedConflictSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + kind: { + type: "string", + enum: ["confirmed_conflict"] + }, + pair: branchPairSchema(), + conflictCause: causeSchema(), + integrationOrder: integrationOrderSchema(), + patches: { + type: "array", + minItems: 1, + items: patchSchema() + } + }, + required: [ + "kind", + "pair", + "conflictCause", + "integrationOrder", + "patches" + ] + } +} + +// clean overlap 예방 응답을 위한 OpenAI structured output schema +function aiCleanOverlapSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + kind: { + type: "string", + enum: ["clean_overlap"] + }, + pair: branchPairSchema(), + overlapCause: causeSchema(), + integrationOrder: integrationOrderSchema(), + preventiveActions: { + type: "array", + minItems: 1, + items: preventiveActionSchema() + } + }, + required: [ + "kind", + "pair", + "overlapCause", + "integrationOrder", + "preventiveActions" + ] + } +} + +// 좌우 순서를 보존하는 branch 조합 식별 schema +function branchPairSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + leftBranchName: { type: "string" }, + rightBranchName: { type: "string" } + }, + required: ["leftBranchName", "rightBranchName"] + } +} + +// conflict 또는 overlap 원인과 관련 파일 schema +function causeSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + summary: { type: "string" }, + files: stringArraySchema() + }, + required: ["summary", "files"] + } +} + +// merge 또는 rebase 작업 순서 schema +function integrationOrderSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + strategy: { + type: "string", + enum: ["merge", "rebase"] + }, + firstBranchName: { type: "string" }, + secondBranchName: { type: "string" }, + reason: { type: "string" }, + steps: { + ...stringArraySchema(), + minItems: 1 + } + }, + required: [ + "strategy", + "firstBranchName", + "secondBranchName", + "reason", + "steps" + ] + } +} + +// 확정 conflict의 파일별 patch schema +function patchSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + filePath: { type: "string" }, + patch: { type: "string" }, + reason: { type: "string" } + }, + required: ["filePath", "patch", "reason"] + } +} + +// clean overlap의 예방 조치 schema +function preventiveActionSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + description: { type: "string" }, + files: stringArraySchema() + }, + required: ["title", "description", "files"] + } +} + +// 문자열 목록 field의 공통 schema +function stringArraySchema(): Record { + return { + type: "array", + items: { type: "string" } + } +} + // AI recommended action 하나의 JSON 구조를 OpenAI structured output schema로 표현 function recommendedActionSchema(): Record { return { diff --git a/tests/ai/openAiPredictionClient.test.ts b/tests/ai/openAiPredictionClient.test.ts index b47ee2b..9914b59 100644 --- a/tests/ai/openAiPredictionClient.test.ts +++ b/tests/ai/openAiPredictionClient.test.ts @@ -120,6 +120,50 @@ test("sends batch response schema to OpenAI", async () => { assert.equal(predictionSchema?.properties?.falsePositiveNotes, undefined) }) +// 확정 conflict 요청이 patch 전용 schema를 사용하는지 확인 +test("sends confirmed conflict pair response schema to OpenAI", async () => { + const spy = fetchSpy(validOpenAiResponse()) + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: spy + }) + + await client.predict(confirmedConflictPairPrompt()) + + const body = JSON.parse(String(spy.requests[0]?.init.body)) as PairRequestBody + const schema = body.text.format.schema + + assert.equal(body.text.format.name, "ai_prediction_pair_confirmed_conflict") + assert.equal(schema.additionalProperties, false) + assert.notEqual(schema.properties.conflictCause, undefined) + assert.notEqual(schema.properties.patches, undefined) + assert.equal(schema.properties.overlapCause, undefined) + assert.equal(schema.properties.preventiveActions, undefined) + assert.equal(schema.properties.patches?.minItems, 1) +}) + +// clean overlap 요청이 예방 조치 전용 schema를 사용하는지 확인 +test("sends clean overlap pair response schema to OpenAI", async () => { + const spy = fetchSpy(validOpenAiResponse()) + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: spy + }) + + await client.predict(cleanOverlapPairPrompt()) + + const body = JSON.parse(String(spy.requests[0]?.init.body)) as PairRequestBody + const schema = body.text.format.schema + + assert.equal(body.text.format.name, "ai_prediction_pair_clean_overlap") + assert.equal(schema.additionalProperties, false) + assert.notEqual(schema.properties.overlapCause, undefined) + assert.notEqual(schema.properties.preventiveActions, undefined) + assert.equal(schema.properties.conflictCause, undefined) + assert.equal(schema.properties.patches, undefined) + assert.equal(schema.properties.preventiveActions?.minItems, 1) +}) + // OpenAI HTTP 실패가 branch 단위 failed result로 격리될 수 있도록 Error로 노출되는지 확인 test("throws when OpenAI request fails", async () => { const client = new OpenAiPredictionClient({ @@ -185,6 +229,20 @@ type FetchSpy = typeof fetch & { }> } +type PairRequestBody = { + text: { + format: { + name: string + schema: { + additionalProperties: boolean + properties: Record + } + } + } +} + // network 호출 없이 OpenAI response와 HTTP 상태를 주입하는 fetch 대역 function fetchSpy( body: unknown, @@ -235,6 +293,22 @@ function batchPrompt(): AiPredictionPrompt { } } +function confirmedConflictPairPrompt(): AiPredictionPrompt { + return { + systemPrompt: "Return confirmed conflict JSON only.", + userPrompt: "{\"pair\":{\"leftBranchName\":\"feature/a\",\"rightBranchName\":\"feature/b\"}}", + responseShape: "predictionPairConfirmedConflict" + } +} + +function cleanOverlapPairPrompt(): AiPredictionPrompt { + return { + systemPrompt: "Return clean overlap JSON only.", + userPrompt: "{\"pair\":{\"leftBranchName\":\"feature/a\",\"rightBranchName\":\"feature/b\"}}", + responseShape: "predictionPairCleanOverlap" + } +} + // OpenAI Responses API가 반환하는 JSON text 응답 fixture function validOpenAiResponse(): unknown { return { From cb566139f74a88411feafd54da1117392ff29c25 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:27:27 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9=20AI=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionPairResponseValidator.ts | 380 ++++++++++++++++++ .../predictionPairResponseValidator.test.ts | 198 +++++++++ 2 files changed, 578 insertions(+) create mode 100644 src/ai/predictionPairResponseValidator.ts create mode 100644 tests/ai/predictionPairResponseValidator.test.ts diff --git a/src/ai/predictionPairResponseValidator.ts b/src/ai/predictionPairResponseValidator.ts new file mode 100644 index 0000000..abb38e8 --- /dev/null +++ b/src/ai/predictionPairResponseValidator.ts @@ -0,0 +1,380 @@ +import type { BranchComparisonPair } from "../branches/types.js" +import type { + AiCleanOverlapResponse, + AiConfirmedConflictResponse, + AiPredictionPairEvidencePayload, + AiPredictionPairIntegrationOrder, + AiPredictionPairPatch, + AiPredictionPairPreventiveAction, + AiPredictionPairResponse +} from "./types.js" + +// unknown provider 응답을 branch 조합 상태에 맞는 결과로 검증 +export function validate( + response: unknown, + payload: AiPredictionPairEvidencePayload +): AiPredictionPairResponse { + return payload.targetStatus === "confirmed_conflict" + ? confirmedConflictResponseFor(response, payload) + : cleanOverlapResponseFor(response, payload) +} + +// 확정 conflict 응답의 구조와 evidence 참조 범위 검증 +function confirmedConflictResponseFor( + response: unknown, + payload: AiPredictionPairEvidencePayload +): AiConfirmedConflictResponse { + const value = objectFor(response, "response") + literalFor(value.kind, "response.kind", "confirmed_conflict") + assertOnlyKeys(value, "response", [ + "kind", + "pair", + "conflictCause", + "integrationOrder", + "patches" + ]) + const pair = pairFor(value.pair, "response.pair") + assertMatchingPair(pair, payload.pair) + const conflictCause = causeFor( + value.conflictCause, + "response.conflictCause", + evidenceFilesFor(payload) + ) + const integrationOrder = integrationOrderFor( + value.integrationOrder, + "response.integrationOrder", + payload.pair + ) + const patchFiles = patchFilesFor(payload) + const patches = nonEmptyArrayFor(value.patches, "response.patches") + .map((patch, index) => patchFor( + patch, + `response.patches[${index}]`, + patchFiles + )) + + return { + kind: "confirmed_conflict", + pair, + conflictCause, + integrationOrder, + patches + } +} + +// clean overlap 응답의 구조와 evidence 참조 범위 검증 +function cleanOverlapResponseFor( + response: unknown, + payload: AiPredictionPairEvidencePayload +): AiCleanOverlapResponse { + const value = objectFor(response, "response") + literalFor(value.kind, "response.kind", "clean_overlap") + assertOnlyKeys(value, "response", [ + "kind", + "pair", + "overlapCause", + "integrationOrder", + "preventiveActions" + ]) + const pair = pairFor(value.pair, "response.pair") + assertMatchingPair(pair, payload.pair) + const files = evidenceFilesFor(payload) + const overlapCause = causeFor( + value.overlapCause, + "response.overlapCause", + files + ) + const integrationOrder = integrationOrderFor( + value.integrationOrder, + "response.integrationOrder", + payload.pair + ) + const preventiveActions = nonEmptyArrayFor( + value.preventiveActions, + "response.preventiveActions" + ).map((action, index) => preventiveActionFor( + action, + `response.preventiveActions[${index}]`, + files + )) + + return { + kind: "clean_overlap", + pair, + overlapCause, + integrationOrder, + preventiveActions + } +} + +// 응답의 ordered pair가 요청한 branch 조합과 같은지 검증 +function pairFor( + response: unknown, + path: string +): BranchComparisonPair { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["leftBranchName", "rightBranchName"]) + + return { + leftBranchName: stringFor(value.leftBranchName, `${path}.leftBranchName`), + rightBranchName: stringFor(value.rightBranchName, `${path}.rightBranchName`) + } +} + +// conflict 또는 overlap 원인과 관련 파일 목록 검증 +function causeFor( + response: unknown, + path: string, + evidenceFiles: ReadonlySet +): { + summary: string + files: string[] +} { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["summary", "files"]) + const files = nonEmptyStringArrayFor(value.files, `${path}.files`) + assertEvidenceFiles(files, `${path}.files`, evidenceFiles) + + return { + summary: stringFor(value.summary, `${path}.summary`), + files + } +} + +// merge 또는 rebase 순서가 대상 branch 두 개만 사용하는지 검증 +function integrationOrderFor( + response: unknown, + path: string, + pair: BranchComparisonPair +): AiPredictionPairIntegrationOrder { + const value = objectFor(response, path) + assertOnlyKeys(value, path, [ + "strategy", + "firstBranchName", + "secondBranchName", + "reason", + "steps" + ]) + const strategy = strategyFor(value.strategy, `${path}.strategy`) + const firstBranchName = stringFor( + value.firstBranchName, + `${path}.firstBranchName` + ) + const secondBranchName = stringFor( + value.secondBranchName, + `${path}.secondBranchName` + ) + assertIntegrationBranches(firstBranchName, secondBranchName, pair) + + return { + strategy, + firstBranchName, + secondBranchName, + reason: stringFor(value.reason, `${path}.reason`), + steps: nonEmptyStringArrayFor(value.steps, `${path}.steps`) + } +} + +// 확정 conflict의 patch가 제공된 파일만 대상으로 하는지 검증 +function patchFor( + response: unknown, + path: string, + evidenceFiles: ReadonlySet +): AiPredictionPairPatch { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["filePath", "patch", "reason"]) + const filePath = stringFor(value.filePath, `${path}.filePath`) + assertEvidenceFiles([filePath], `${path}.filePath`, evidenceFiles) + + return { + filePath, + patch: stringFor(value.patch, `${path}.patch`), + reason: stringFor(value.reason, `${path}.reason`) + } +} + +// clean overlap 예방 조치가 제공된 파일만 참조하는지 검증 +function preventiveActionFor( + response: unknown, + path: string, + evidenceFiles: ReadonlySet +): AiPredictionPairPreventiveAction { + const value = objectFor(response, path) + assertOnlyKeys(value, path, ["title", "description", "files"]) + const files = nonEmptyStringArrayFor(value.files, `${path}.files`) + assertEvidenceFiles(files, `${path}.files`, evidenceFiles) + + return { + title: stringFor(value.title, `${path}.title`), + description: stringFor(value.description, `${path}.description`), + files + } +} + +// 응답 pair의 좌우 이름과 순서가 요청과 같은지 검증 +function assertMatchingPair( + pair: BranchComparisonPair, + expected: BranchComparisonPair +): void { + if ( + pair.leftBranchName !== expected.leftBranchName || + pair.rightBranchName !== expected.rightBranchName + ) { + throw new Error( + "AI prediction pair response must use matching ordered branch pair" + ) + } +} + +// 작업 순서가 대상 branch 두 개를 중복 없이 포함하는지 검증 +function assertIntegrationBranches( + firstBranchName: string, + secondBranchName: string, + pair: BranchComparisonPair +): void { + const branchNames = new Set([ + pair.leftBranchName, + pair.rightBranchName + ]) + + if ( + firstBranchName === secondBranchName || + !branchNames.has(firstBranchName) || + !branchNames.has(secondBranchName) + ) { + throw new Error( + "AI prediction pair response integration order must use both target branches" + ) + } +} + +// 응답에 포함된 파일이 제공된 evidence 범위를 벗어나지 않는지 검증 +function assertEvidenceFiles( + files: string[], + path: string, + evidenceFiles: ReadonlySet +): void { + if (files.some(file => !evidenceFiles.has(file))) { + throw new Error( + `AI prediction pair response ${path} must use provided evidence files` + ) + } +} + +// 원인과 예방 조치가 참조할 수 있는 모든 evidence 파일 구성 +function evidenceFilesFor( + payload: AiPredictionPairEvidencePayload +): ReadonlySet { + return new Set([ + ...payload.reasons.flatMap(reason => reason.files ?? []), + ...payload.merge.conflictFiles, + ...payload.merge.conflicts.flatMap(conflict => conflict.paths), + ...payload.codeContext.overlapFiles, + ...payload.codeContext.evidence.map(evidence => evidence.filePath) + ]) +} + +// patch가 참조할 수 있는 conflict와 코드 문맥 파일 구성 +function patchFilesFor( + payload: AiPredictionPairEvidencePayload +): ReadonlySet { + return new Set([ + ...payload.merge.conflictFiles, + ...payload.merge.conflicts.flatMap(conflict => conflict.paths), + ...payload.codeContext.overlapFiles, + ...payload.codeContext.evidence.map(evidence => evidence.filePath) + ]) +} + +// 응답 object가 상태별 schema key만 포함하는지 검증 +function assertOnlyKeys( + value: Record, + path: string, + allowedKeys: string[] +): void { + const allowed = new Set(allowedKeys) + + if (Object.keys(value).some(key => !allowed.has(key))) { + throw new Error( + `AI prediction pair response ${path} contains unsupported fields` + ) + } +} + +// unknown 값이 key 접근 가능한 object인지 검증 +function objectFor( + value: unknown, + path: string +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`AI prediction pair response ${path} must be an object`) + } + + return value as Record +} + +// unknown 배열이 한 개 이상의 항목을 포함하는지 검증 +function nonEmptyArrayFor( + value: unknown, + path: string +): unknown[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + `AI prediction pair response ${path} must be a non-empty array` + ) + } + + return value +} + +// unknown 배열이 비어 있지 않은 문자열만 포함하는지 검증 +function nonEmptyStringArrayFor( + value: unknown, + path: string +): string[] { + return nonEmptyArrayFor(value, path) + .map((item, index) => stringFor(item, `${path}[${index}]`)) +} + +// unknown 값이 비어 있지 않은 문자열인지 검증 +function stringFor( + value: unknown, + path: string +): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error( + `AI prediction pair response ${path} must be a non-empty string` + ) + } + + return value +} + +// 응답 상태 문자열이 요청한 상태와 같은지 검증 +function literalFor( + value: unknown, + path: string, + expected: T +): T { + if (value !== expected) { + throw new Error( + `AI prediction pair response ${path} must be ${expected}` + ) + } + + return expected +} + +// 작업 방식이 merge 또는 rebase인지 검증 +function strategyFor( + value: unknown, + path: string +): "merge" | "rebase" { + if (value !== "merge" && value !== "rebase") { + throw new Error( + `AI prediction pair response ${path} must be merge or rebase` + ) + } + + return value +} diff --git a/tests/ai/predictionPairResponseValidator.test.ts b/tests/ai/predictionPairResponseValidator.test.ts new file mode 100644 index 0000000..f9b3c4d --- /dev/null +++ b/tests/ai/predictionPairResponseValidator.test.ts @@ -0,0 +1,198 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { validate } from "../../src/ai/predictionPairResponseValidator.js" +import type { + AiCleanOverlapResponse, + AiConfirmedConflictResponse, + AiPredictionPairEvidencePayload, + AiPredictionPairTargetStatus +} from "../../src/ai/types.js" + +// 확정 conflict 응답을 요청 evidence와 대조해 검증 +test("validates confirmed conflict pair response", () => { + const input = payload("confirmed_conflict") + const response = confirmedConflictResponse() + + assert.deepEqual(validate(response, input), response) +}) + +// clean overlap 응답을 요청 evidence와 대조해 검증 +test("validates clean overlap pair response", () => { + const input = payload("potential_overlap") + const response = cleanOverlapResponse() + + assert.deepEqual(validate(response, input), response) +}) + +// 응답 상태와 ordered pair가 요청과 다르면 거부 +test("rejects mismatched pair response kind and ordered pair", () => { + assert.throws(() => validate( + cleanOverlapResponse(), + payload("confirmed_conflict") + ), /must be confirmed_conflict/) + + assert.throws(() => validate({ + ...confirmedConflictResponse(), + pair: { + leftBranchName: "feature/b", + rightBranchName: "feature/a" + } + }, payload("confirmed_conflict")), /matching ordered branch pair/) +}) + +// 작업 순서에 대상 밖 branch나 같은 branch가 들어가면 거부 +test("rejects invalid pair integration order", () => { + assert.throws(() => validate({ + ...confirmedConflictResponse(), + integrationOrder: { + ...confirmedConflictResponse().integrationOrder, + secondBranchName: "feature/c" + } + }, payload("confirmed_conflict")), /must use both target branches/) + + assert.throws(() => validate({ + ...cleanOverlapResponse(), + integrationOrder: { + ...cleanOverlapResponse().integrationOrder, + secondBranchName: "feature/a" + } + }, payload("potential_overlap")), /must use both target branches/) +}) + +// 제공된 evidence에 없는 원인, patch, 예방 조치 파일을 거부 +test("rejects files outside pair evidence", () => { + assert.throws(() => validate({ + ...confirmedConflictResponse(), + patches: [{ + filePath: "src/unknown.ts", + patch: "@@ -1 +1 @@", + reason: "제공되지 않은 파일" + }] + }, payload("confirmed_conflict")), /must use provided evidence files/) + + assert.throws(() => validate({ + ...cleanOverlapResponse(), + overlapCause: { + summary: "제공되지 않은 파일", + files: ["src/unknown.ts"] + } + }, payload("potential_overlap")), /must use provided evidence files/) +}) + +// 상태별 해결 제안 배열이 비어 있으면 거부 +test("rejects empty pair resolution suggestions", () => { + assert.throws(() => validate({ + ...confirmedConflictResponse(), + patches: [] + }, payload("confirmed_conflict")), /must be a non-empty array/) + + assert.throws(() => validate({ + ...cleanOverlapResponse(), + preventiveActions: [] + }, payload("potential_overlap")), /must be a non-empty array/) +}) + +// 다른 상태의 field가 섞인 응답을 거부 +test("rejects fields outside pair response schema", () => { + assert.throws(() => validate({ + ...cleanOverlapResponse(), + patches: [] + }, payload("potential_overlap")), /contains unsupported fields/) +}) + +function confirmedConflictResponse(): AiConfirmedConflictResponse { + return { + kind: "confirmed_conflict", + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + conflictCause: { + summary: "두 branch가 같은 조건문을 다르게 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "rebase", + firstBranchName: "feature/a", + secondBranchName: "feature/b", + reason: "구조 변경을 먼저 반영해야 함", + steps: ["feature/a 반영", "feature/b rebase"] + }, + patches: [{ + filePath: "src/shared.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + reason: "두 변경 의도를 함께 보존함" + }] + } +} + +function cleanOverlapResponse(): AiCleanOverlapResponse { + return { + kind: "clean_overlap", + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + overlapCause: { + summary: "같은 함수의 인접한 조건을 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: "feature/a", + secondBranchName: "feature/b", + reason: "첫 변경 이후 통합 동작을 확인함", + steps: ["feature/a merge", "feature/b 갱신"] + }, + preventiveActions: [{ + title: "통합 동작 테스트", + description: "두 조건이 함께 실행되는 경우를 확인함", + files: ["src/shared.ts"] + }] + } +} + +function payload( + targetStatus: AiPredictionPairTargetStatus +): AiPredictionPairEvidencePayload { + const confirmedConflict = targetStatus === "confirmed_conflict" + + return { + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + targetStatus, + reasons: [{ + code: confirmedConflict ? "confirmed_conflict" : "same_hunk_overlap", + files: ["src/shared.ts"] + }], + branches: { + left: { + name: "feature/a", + commitOid: "a".repeat(40) + }, + right: { + name: "feature/b", + commitOid: "b".repeat(40) + } + }, + merge: { + status: confirmedConflict ? "confirmed_conflict" : "clean", + mergedTreeOid: "c".repeat(40), + conflictFiles: confirmedConflict ? ["src/shared.ts"] : [], + conflicts: confirmedConflict + ? [{ paths: ["src/shared.ts"], type: "content" }] + : [] + }, + codeContext: { + status: "available", + overlapFiles: ["src/shared.ts"], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + } + } +} From accda50c1c79cab202e49fb3125edc0256730f96 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:29:02 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=EB=B3=84=20AI=20prediction=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionPairRunner.ts | 52 +++++++ src/index.ts | 29 +++- tests/ai/predictionPairRunner.test.ts | 214 ++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/ai/predictionPairRunner.ts create mode 100644 tests/ai/predictionPairRunner.test.ts diff --git a/src/ai/predictionPairRunner.ts b/src/ai/predictionPairRunner.ts new file mode 100644 index 0000000..f310f96 --- /dev/null +++ b/src/ai/predictionPairRunner.ts @@ -0,0 +1,52 @@ +import { build as buildPrompt } from "./predictionPairPromptBuilder.js" +import { validate as validateResponse } from "./predictionPairResponseValidator.js" +import type { + AiPredictionClient, + AiPredictionPairEvidencePayload, + AiPredictionPairResult, + AiPredictionPromptBuildOptions +} from "./types.js" + +// branch 조합을 입력 순서대로 실행하고 provider와 validation 실패를 조합별로 격리 +export async function predict( + payloads: AiPredictionPairEvidencePayload[], + client: AiPredictionClient, + options: AiPredictionPromptBuildOptions = {} +): Promise { + const results: AiPredictionPairResult[] = [] + + for (const payload of payloads) { + results.push(await resultFor(payload, client, options)) + } + + return results +} + +// branch 조합 하나의 prompt 생성, provider 호출, 응답 검증 실행 +async function resultFor( + payload: AiPredictionPairEvidencePayload, + client: AiPredictionClient, + options: AiPredictionPromptBuildOptions +): Promise { + try { + const prompt = buildPrompt(payload, options) + const response = await client.predict(prompt) + + return { + status: "predicted", + pair: payload.pair, + response: validateResponse(response, payload) + } + } catch (error) { + return { + status: "failed", + pair: payload.pair, + errorMessage: errorMessageFor(error) + } + } +} + +// unknown provider 또는 validation 오류를 report 가능한 문자열로 변환 +function errorMessageFor(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/index.ts b/src/index.ts index b49ae51..7210fcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,11 +18,19 @@ export { build as buildAiPredictionPrompt, buildBatch as buildAiPredictionBatchPrompt } from "./ai/predictionPromptBuilder.js" +export { + build as buildAiPredictionPairPrompt +} from "./ai/predictionPairPromptBuilder.js" export { DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT, DEFAULT_AI_PREDICTION_SYSTEM_PROMPT } from "./ai/promptTemplates.js" +export { + DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT, + DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT +} from "./ai/predictionPairPromptTemplates.js" export { predict as predictMergeRisksWithAi } from "./ai/predictionRunner.js" +export { predict as predictBranchPairsWithAi } from "./ai/predictionPairRunner.js" export { DEFAULT_AI_PREDICTION_TARGET_STATUS, select as selectAiPredictionTargets @@ -31,6 +39,9 @@ export { validate as validateAiPredictionResponse, validateBatch as validateAiPredictionBatchResponse } from "./ai/predictionResponseValidator.js" +export { + validate as validateAiPredictionPairResponse +} from "./ai/predictionPairResponseValidator.js" export { collectGitMergeSignal } from "./git/gitMergeSignalCollector.js" export { send as sendMergeRiskReport } from "./reportChannels/reportChannel.js" export { analyze as analyzeBranchMergeRisks } from "./risks/riskAnalyzer.js" @@ -52,6 +63,19 @@ export type { AiPredictionEvidencePayload, AiPredictionFailedResult, AiPredictionFailureDebugEvent, + AiPredictionPairBranchMetadata, + AiPredictionPairCodeContext, + AiPredictionPairCodeContextStatus, + AiPredictionPairEvidencePayload, + AiPredictionPairFailedResult, + AiPredictionPairIntegrationOrder, + AiPredictionPairMergeStatus, + AiPredictionPairPatch, + AiPredictionPairPredictedResult, + AiPredictionPairPreventiveAction, + AiPredictionPairResponse, + AiPredictionPairResult, + AiPredictionPairTargetStatus, AiPredictionPrompt, AiPredictionPromptBuildOptions, AiPredictionPromptDebugEvent, @@ -62,10 +86,13 @@ export type { AiPredictionRunOptions, AiPredictionSkippedResult, AiRecommendedAction, - AiRecommendedActionPriority + AiRecommendedActionPriority, + AiCleanOverlapResponse, + AiConfirmedConflictResponse } from "./ai/types.js" export type { + BranchComparisonPair, BranchContext, BranchCheckMetadata, BranchPullRequestMetadata, diff --git a/tests/ai/predictionPairRunner.test.ts b/tests/ai/predictionPairRunner.test.ts new file mode 100644 index 0000000..7447287 --- /dev/null +++ b/tests/ai/predictionPairRunner.test.ts @@ -0,0 +1,214 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + predictBranchPairsWithAi, + type AiCleanOverlapResponse, + type AiConfirmedConflictResponse, + type AiPredictionClient, + type AiPredictionPairEvidencePayload, + type AiPredictionPairTargetStatus, + type AiPredictionPrompt, + type BranchComparisonPair +} from "../../src/index.js" + +// branch 조합마다 provider를 한 번 호출하고 입력 순서로 결과 반환 +test("predicts branch pairs sequentially in input order", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const snapshot = JSON.stringify(inputs) + const client = new AiPredictionClientSpy() + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["predicted", "predicted"]) + assert.deepEqual(results.map(result => result.pair), inputs.map(input => input.pair)) + assert.deepEqual(client.prompts.map(prompt => prompt.responseShape), [ + "predictionPairConfirmedConflict", + "predictionPairCleanOverlap" + ]) + assert.equal(JSON.stringify(inputs), snapshot) +}) + +// 한 조합의 provider 실패 후 다음 조합을 계속 처리 +test("isolates provider failure to one branch pair", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const client = new AiPredictionClientSpy([ + new Error("provider failed"), + cleanOverlapResponse(inputs[1]!.pair) + ]) + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["failed", "predicted"]) + assert.match( + results[0]?.status === "failed" ? results[0].errorMessage : "", + /provider failed/ + ) + assert.equal(client.prompts.length, 2) +}) + +// 한 조합의 validation 실패 후 유효한 다음 응답 유지 +test("isolates validation failure to one branch pair", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const client = new AiPredictionClientSpy([ + cleanOverlapResponse(inputs[0]!.pair), + cleanOverlapResponse(inputs[1]!.pair) + ]) + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["failed", "predicted"]) + assert.match( + results[0]?.status === "failed" ? results[0].errorMessage : "", + /must be confirmed_conflict/ + ) +}) + +// 모든 provider 호출이 실패해도 조합별 failed 결과를 반환 +test("returns failed result for every pair when provider is unavailable", async () => { + const inputs = [ + payload("feature/a", "feature/b", "confirmed_conflict"), + payload("feature/c", "feature/d", "potential_overlap") + ] + const client = new AiPredictionClientSpy([ + new Error("provider unavailable"), + new Error("provider unavailable") + ]) + + const results = await predictBranchPairsWithAi(inputs, client) + + assert.deepEqual(results.map(result => result.status), ["failed", "failed"]) + assert.equal(client.prompts.length, 2) +}) + +class AiPredictionClientSpy implements AiPredictionClient { + prompts: AiPredictionPrompt[] = [] + private responseIndex = 0 + + constructor(private readonly responses: Array = []) {} + + async predict(prompt: AiPredictionPrompt): Promise { + this.prompts.push(prompt) + const configured = this.responses[this.responseIndex] + this.responseIndex += 1 + + if (configured instanceof Error) { + throw configured + } + + return configured ?? responseFor(prompt) + } +} + +function responseFor(prompt: AiPredictionPrompt): unknown { + const input = JSON.parse(prompt.userPrompt) as AiPredictionPairEvidencePayload + + return prompt.responseShape === "predictionPairConfirmedConflict" + ? confirmedConflictResponse(input.pair) + : cleanOverlapResponse(input.pair) +} + +function confirmedConflictResponse( + pair: BranchComparisonPair +): AiConfirmedConflictResponse { + return { + kind: "confirmed_conflict", + pair, + conflictCause: { + summary: "두 branch가 같은 조건문을 다르게 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "rebase", + firstBranchName: pair.leftBranchName, + secondBranchName: pair.rightBranchName, + reason: "구조 변경을 먼저 반영해야 함", + steps: ["첫 branch 반영", "두 번째 branch rebase"] + }, + patches: [{ + filePath: "src/shared.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + reason: "두 변경 의도를 함께 보존함" + }] + } +} + +function cleanOverlapResponse( + pair: BranchComparisonPair +): AiCleanOverlapResponse { + return { + kind: "clean_overlap", + pair, + overlapCause: { + summary: "같은 함수의 인접한 조건을 수정함", + files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: pair.leftBranchName, + secondBranchName: pair.rightBranchName, + reason: "첫 변경 이후 통합 동작을 확인함", + steps: ["첫 branch merge", "두 번째 branch 갱신"] + }, + preventiveActions: [{ + title: "통합 동작 테스트", + description: "두 조건이 함께 실행되는 경우를 확인함", + files: ["src/shared.ts"] + }] + } +} + +function payload( + leftBranchName: string, + rightBranchName: string, + targetStatus: AiPredictionPairTargetStatus +): AiPredictionPairEvidencePayload { + const confirmedConflict = targetStatus === "confirmed_conflict" + + return { + pair: { + leftBranchName, + rightBranchName + }, + targetStatus, + reasons: [{ + code: confirmedConflict ? "confirmed_conflict" : "same_hunk_overlap", + files: ["src/shared.ts"] + }], + branches: { + left: { + name: leftBranchName, + commitOid: "a".repeat(40) + }, + right: { + name: rightBranchName, + commitOid: "b".repeat(40) + } + }, + merge: { + status: confirmedConflict ? "confirmed_conflict" : "clean", + mergedTreeOid: "c".repeat(40), + conflictFiles: confirmedConflict ? ["src/shared.ts"] : [], + conflicts: confirmedConflict + ? [{ paths: ["src/shared.ts"], type: "content" }] + : [] + }, + codeContext: { + status: "available", + overlapFiles: ["src/shared.ts"], + evidence: [], + includedFileCount: 0, + includedHunkCount: 0, + omittedFileCount: 0, + omittedHunkCount: 0 + } + } +} From 79e262f56c1eb98631ad4fde053322c8b33a7c21 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:02:28 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20branch=20=EC=A1=B0=ED=95=A9=20prompt?= =?UTF-8?q?=EC=9D=98=20=EC=88=9C=EC=84=9C=20=EB=B3=B4=EC=A1=B4=20=EC=A7=80?= =?UTF-8?q?=EC=B9=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionPairPromptTemplates.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ai/predictionPairPromptTemplates.ts b/src/ai/predictionPairPromptTemplates.ts index 0a4d3a9..d5386df 100644 --- a/src/ai/predictionPairPromptTemplates.ts +++ b/src/ai/predictionPairPromptTemplates.ts @@ -2,6 +2,7 @@ export const DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT = [ "You are Watcher's confirmed merge conflict resolution assistant.", "Use only the provided branch pair evidence and code context.", + "Preserve the exact 'leftBranchName' and 'rightBranchName' from the input in the 'pair' object without swapping them.", "Do not recalculate or overwrite the deterministic merge status or graph reasons.", "Explain the confirmed conflict cause from the provided code only.", "Recommend a merge or rebase order using only the two provided branch names.", @@ -39,6 +40,7 @@ export const DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT = [ export const DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT = [ "You are Watcher's clean merge overlap prevention assistant.", "Use only the provided branch pair evidence and code context.", + "Preserve the exact 'leftBranchName' and 'rightBranchName' from the input in the 'pair' object without swapping them.", "The provided merge status is clean and is not a confirmed conflict.", "Do not recalculate or overwrite the deterministic merge status or graph reasons.", "Do not claim that a conflict exists or will occur.", From a5f70cb60754689496ac81739f82c973908cfdb2 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:04:25 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20AI=20=EC=9D=91=EB=8B=B5=20=EC=8B=9D?= =?UTF-8?q?=EB=B3=84=EC=9E=90=20=EA=B3=B5=EB=B0=B1=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionPairResponseValidator.ts | 37 ++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/ai/predictionPairResponseValidator.ts b/src/ai/predictionPairResponseValidator.ts index abb38e8..350ca7d 100644 --- a/src/ai/predictionPairResponseValidator.ts +++ b/src/ai/predictionPairResponseValidator.ts @@ -116,8 +116,14 @@ function pairFor( assertOnlyKeys(value, path, ["leftBranchName", "rightBranchName"]) return { - leftBranchName: stringFor(value.leftBranchName, `${path}.leftBranchName`), - rightBranchName: stringFor(value.rightBranchName, `${path}.rightBranchName`) + leftBranchName: trimmedStringFor( + value.leftBranchName, + `${path}.leftBranchName` + ), + rightBranchName: trimmedStringFor( + value.rightBranchName, + `${path}.rightBranchName` + ) } } @@ -132,7 +138,7 @@ function causeFor( } { const value = objectFor(response, path) assertOnlyKeys(value, path, ["summary", "files"]) - const files = nonEmptyStringArrayFor(value.files, `${path}.files`) + const files = nonEmptyTrimmedStringArrayFor(value.files, `${path}.files`) assertEvidenceFiles(files, `${path}.files`, evidenceFiles) return { @@ -156,11 +162,11 @@ function integrationOrderFor( "steps" ]) const strategy = strategyFor(value.strategy, `${path}.strategy`) - const firstBranchName = stringFor( + const firstBranchName = trimmedStringFor( value.firstBranchName, `${path}.firstBranchName` ) - const secondBranchName = stringFor( + const secondBranchName = trimmedStringFor( value.secondBranchName, `${path}.secondBranchName` ) @@ -183,7 +189,7 @@ function patchFor( ): AiPredictionPairPatch { const value = objectFor(response, path) assertOnlyKeys(value, path, ["filePath", "patch", "reason"]) - const filePath = stringFor(value.filePath, `${path}.filePath`) + const filePath = trimmedStringFor(value.filePath, `${path}.filePath`) assertEvidenceFiles([filePath], `${path}.filePath`, evidenceFiles) return { @@ -201,7 +207,7 @@ function preventiveActionFor( ): AiPredictionPairPreventiveAction { const value = objectFor(response, path) assertOnlyKeys(value, path, ["title", "description", "files"]) - const files = nonEmptyStringArrayFor(value.files, `${path}.files`) + const files = nonEmptyTrimmedStringArrayFor(value.files, `${path}.files`) assertEvidenceFiles(files, `${path}.files`, evidenceFiles) return { @@ -336,6 +342,15 @@ function nonEmptyStringArrayFor( .map((item, index) => stringFor(item, `${path}[${index}]`)) } +// branch 이름과 evidence 파일 경로의 불필요한 양끝 공백 제거 +function nonEmptyTrimmedStringArrayFor( + value: unknown, + path: string +): string[] { + return nonEmptyArrayFor(value, path) + .map((item, index) => trimmedStringFor(item, `${path}[${index}]`)) +} + // unknown 값이 비어 있지 않은 문자열인지 검증 function stringFor( value: unknown, @@ -350,6 +365,14 @@ function stringFor( return value } +// 식별자 비교 전에 provider가 추가한 양끝 공백 제거 +function trimmedStringFor( + value: unknown, + path: string +): string { + return stringFor(value, path).trim() +} + // 응답 상태 문자열이 요청한 상태와 같은지 검증 function literalFor( value: unknown,