Day 23 的 codeReviewFlow 已經會指出問題了:第幾行、哪一類、為什麼。
不過它只會「說」,suggestion 是一段給人看的文字,程式沒辦法拿去改檔案。
今天要加進來的能力是 補丁(patch),並寫出第一版的 patchFlow:
先審查,再讓 Gemini 產出「替換用的程式碼」,最後寫進一份新檔案。
今天做三件事:
1. 定義補丁的 Zod Schema 與 System Prompt
2. 寫一個 applyPatches 函式,把補丁套用到原始碼上
3. 用 defineFlow 組成 patchFlow,對三個 Bug 各產出一份修復檔
今天只負責「產生修復檔」,不驗證它改得對不對。驗證留到 Day 25。
要讓程式自動改檔案,光有「問題在第 3 行」還不夠,還要知道:改哪一段、改成什麼。
最直覺的做法是叫 Gemini 把整份檔案重寫一遍。但整份重寫有兩個毛病:模型可能順手「優化」你沒要它改的地方,而且你得自己比對才知道它到底動了什麼。
所以今天改用補丁的做法,每一個補丁只回答四件事:
startLine 從第幾行開始換
endLine 換到第幾行(含這一行)
replacement 換成什麼程式碼
reason 為什麼這樣換
這四個欄位,剛好就是 Day 23 打好的行號基礎加上「修改後代碼」與「修改理由」。補丁範圍以外的每一行,都會原封不動。
patchFlow 要用到 Day 23 的 Flow,所以先在 src/codeReviewFlow.mjs 加上兩個 export:
// 原本是 const ai = genkit(...),前面加上 export
export const ai = genkit({
plugins: [googleAI()],
});
// 原本是 function addLineNumbers(...),前面加上 export
export function addLineNumbers(code) {
// 內容不變
}
把 ai 匯出來,是為了讓兩個 Flow 共用同一個 Genkit 實例。這樣 Developer UI 才會同時看到兩個 Flow,Trace 也能串在一起。
另外在 .gitignore 加一行,因為修復檔每次都會重新產生:
fixed/
今天新增的檔案如下:
devpulse/
├─ samples/ ← Day 22(原檔,今天不會動它)
├─ tests/ ← Day 22
├─ src/
│ ├─ codeReviewFlow.mjs ← Day 23(加兩個 export)
│ └─ patchFlow.mjs ← 今天新增
├─ fixed/ ← 今天自動產生
├─ .gitignore ← 加上 fixed/
└─ ...
修復檔會寫到 fixed/,檔名跟原檔一樣(例如 fixed/sql.cjs)。原檔不動,三個範例各有對應的修復檔,Day 25 要比對也很方便。
在 src/patchFlow.mjs 先寫最上面的引入與 Schema:
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { googleAI } from "@genkit-ai/google-genai";
import { z } from "genkit";
import { addLineNumbers, ai, codeReviewFlow } from "./codeReviewFlow.mjs";
// 一個補丁:把 startLine ~ endLine 換成 replacement
const PatchSchema = z.object({
startLine: z.number().describe("要被替換的第一行,從 1 開始"),
endLine: z
.number()
.describe("要被替換的最後一行(含這一行),只改一行時與 startLine 相同"),
replacement: z
.string()
.describe("取代該範圍的新程式碼,純程式碼,不含行號與說明文字"),
reason: z.string().describe("用繁體中文向初學者說明為什麼這樣改"),
});
const PatchResultSchema = z.object({
patches: z.array(PatchSchema).describe("沒有需要修改的地方時回傳空陣列"),
});
這裡有兩個重點:
startLine 和 endLine 的意思是「包含頭尾」,describe 裡把這件事講白,模型才不容易少算一行。replacement 的 describe 直接寫明「不含行號」,因為送給模型的程式碼每行開頭都有行號,模型有可能順手抄進去。接著寫補丁用的 System Prompt:
const SYSTEM_PROMPT = `你是一位謹慎的資深工程師,負責把審查結果轉成可以直接套用的補丁。
請遵守以下規則:
1. 只修改審查結果提到的問題,不要順手重構或調整不相關的程式碼。
2. 行號就是程式碼每行開頭的數字,startLine 與 endLine 都包含在內。
3. replacement 只放純程式碼:不要行號、不要 markdown 的程式碼圍欄、不要說明文字。
4. replacement 要保留原本的縮排,並且能直接取代 startLine 到 endLine。
5. 補丁之間的行號範圍不可以重疊;能用一個補丁解決的問題,就不要拆成多個。
6. reason 用繁體中文,說明「為什麼這樣改才對」。
7. 如果審查結果沒有任何問題,patches 請回傳空陣列。`;
規則 1 守住範圍,規則 3、4 是「輸出乾淨修復代碼」的關鍵,規則 5 則是替等一下的補丁演算法先鋪路。
溫度設為 0.1,比審查時的 0.2 再保守一點:修復要的是穩,不是變化。
先寫補丁演算法。它的任務很單純:把一組補丁套到原始碼上。
function applyPatches(code, patches) {
const lines = code.trimEnd().split("\n");
const total = lines.length;
// 行號由大到小排,從檔案最下面開始改
const sorted = [...patches].sort((a, b) => b.startLine - a.startLine);
let prevStart = Infinity;
for (const p of sorted) {
const outOfRange =
p.startLine < 1 || p.endLine < p.startLine || p.endLine > total;
if (outOfRange || p.endLine >= prevStart) {
throw new Error(`補丁行號不合法或互相重疊:${p.startLine}-${p.endLine}`);
}
lines.splice(
p.startLine - 1,
p.endLine - p.startLine + 1,
...p.replacement.split("\n"),
);
prevStart = p.startLine;
}
return lines.join("\n") + "\n";
}
再來是 Flow:先呼叫 Day 23 的審查,再請 Gemini 依審查結果寫補丁,最後套用並寫檔。
export const patchFlow = ai.defineFlow(
{
name: "patchFlow",
inputSchema: z.object({ filePath: z.string() }),
outputSchema: z.object({
outputPath: z.string(),
patches: z.array(PatchSchema),
}),
},
async ({ filePath }) => {
const code = await ai.run("read-source-file", async () =>
readFile(filePath, "utf-8"),
);
// Flow 可以像一般函式一樣被呼叫
const review = await codeReviewFlow({ filePath });
let patches = [];
if (review.issues.length > 0) {
const { output } = await ai.generate({
model: googleAI.model("gemini-flash-latest"),
system: SYSTEM_PROMPT,
prompt: `原始程式碼(每行開頭的數字是行號):\n\n${addLineNumbers(code)}\n\n審查結果:\n${JSON.stringify(review.issues, null, 2)}`,
config: { temperature: 0.1 },
output: { schema: PatchResultSchema },
});
if (output == null) {
throw new Error("Gemini 回傳的內容不符合 PatchResultSchema");
}
patches = output.patches;
}
const fixedCode = applyPatches(code, patches);
const outputPath = join("fixed", basename(filePath));
await ai.run("write-fixed-file", async () => {
await mkdir("fixed", { recursive: true });
await writeFile(outputPath, fixedCode, "utf-8");
});
return { outputPath, patches };
},
);
重點解析:
applyPatches 一律直接丟錯誤,不會把壞掉的結果寫進 fixed/。codeReviewFlow({ filePath }):Flow 本身就是可以呼叫的函式,所以「審查 → 補丁」可以直接串起來,診斷和修復也維持在兩次獨立的呼叫。review.issues.length > 0:沒有問題就不必再問 Gemini,原樣複製一份到 fixed/,這樣三個範例的輸出結構一致。這次入口換成 patchFlow.mjs,它會把 codeReviewFlow 一起載入,所以兩個 Flow 都會出現在 Developer UI:
genkit start -- node --env-file=.env --watch src/patchFlow.mjs
開啟 http://localhost:4000,在 Run 頁籤選擇 patchFlow,輸入:
{ "filePath": "samples/sql.cjs" }
三個範例各跑一次。預期的補丁方向如下:
| 範例 | 預期補丁 |
|---|---|
async-await.cjs |
把 forEach 改成 for...of 搭配 await(或改用 Promise.all),兩種都算合理 |
sql.cjs |
SQL 改成 ? 佔位符,而且 db.query 那一行要一起補上參數陣列 |
boundary.cjs |
對 profile 缺少的情況加上防禦(例如 ?. 或提早 return) |
以 sql.cjs 為例,輸出大致會長這樣(示意):
{
"outputPath": "fixed/sql.cjs",
"patches": [
{
"startLine": 2,
"endLine": 4,
"replacement": " const sql = \"SELECT * FROM users WHERE name = ?\";\n return db.query(sql, [userName]);",
"reason": "SQL 裡改用 ? 佔位符,讓 userName 只被當成資料,不會被當成 SQL 語法執行。"
}
]
}
跑完之後,用兩個指令檢查結果:
git diff --no-index samples/sql.cjs fixed/sql.cjs
node --check fixed/sql.cjs
第一個看補丁到底改了哪幾行,第二個確認修復檔至少語法沒壞。
可以觀察到三件事:
fixed/ 裡多了三份修復檔,samples/ 的原檔一個字都沒變。db.query 的參數,補丁照樣套得進去,程式卻還是壞的。這時候 npm test 仍然是三個紅燈,因為測試載入的還是 samples/ 的原檔。「AI 說這樣改就對了」只是一個說法,最後還是要由測試來驗證,這就是 Day 25 的工作。
第一個小觀念:改檔案要由下往上改。每一個補丁都可能讓行數變多或變少,從上面開始改,下面的行號就全部漂移了。
第二個小提醒:補丁的行號範圍一旦重疊或超出檔案,寧可直接報錯,也不要硬寫。壞掉的補丁寫進檔案,會變成 Day 25 很難查的「假紅燈」,因為你分不清是 AI 改錯,還是自己的程式把檔案寫壞。
今天的 patchFlow 已經會「動手」改了,但改得對不對,還沒有人驗證。
今天完成:
✅ 匯出 ai 與 addLineNumbers,讓兩個 Flow 共用
✅ 定義 PatchSchema
✅ 撰寫補丁用 System Prompt
✅ 實作 applyPatches(由下往上套用,並擋下不合法的補丁)
✅ 完成 patchFlow,把修復檔寫進 fixed/
✅ 對三個 Bug 各產出一份修復檔
下一篇進入 Day 25:讓 Jest 改跑 fixed/ 裡的修復檔,親眼看三個紅燈變綠燈,驗證 AI 的補丁到底改對了沒有。