diff --git a/README.md b/README.md index 476859d..bf0f901 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ 5. 將提示詞放到 ./app/prompts 內供程式讀取 6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1 7. 讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼 -8. 階段五完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1 +8. 階段七驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]` 9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量 # 使用說明 @@ -198,4 +198,4 @@ jobs: contents: write pull-requests: write issues: write -``` \ No newline at end of file +``` diff --git a/TODO.md b/TODO.md index 9b6d43e..489da49 100644 --- a/TODO.md +++ b/TODO.md @@ -33,8 +33,8 @@ - 可驗收紀錄情境:當最終 findings 至少有 1 筆舊問題、1 筆新非嚴重問題或 1 筆新嚴重問題時,log 會分別出現 `舊問題 comment 發布`、`新問題(非嚴重)comment 發布`、`嚴重問題 comment 發布`;其中嚴重問題會逐筆發 comment。 ## 階段七:階段六後驗證 JSON 格式 -- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1。 -- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有「嘗試修正」訊息與備份路徑,修正失敗時 workflow 狀態為失敗。 +- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`。 +- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有 AI 修正嘗試與修正後再次驗證的訊息;若檔案不存在,會在驗證完成後看到建立並寫入 `[]` 的訊息;修正失敗時 workflow 狀態為失敗。 - 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。 ## 階段八:記憶區 commit/push 與錯誤處理 diff --git a/app/json.js b/app/json.js new file mode 100644 index 0000000..a893529 --- /dev/null +++ b/app/json.js @@ -0,0 +1,66 @@ +import fs from 'fs'; +import path from 'path'; +import { chat } from './llm.js'; + +function stripCodeFence(text) { + return String(text) + .trim() + .replace(/^```[a-zA-Z0-9_-]*\n?/, '') + .replace(/```$/, '') + .trim(); +} + +async function repairJSONArrayWithAI(fullPath, label, rawText) { + const systemPrompt = `你是 JSON 修復器。請修正使用者提供的內容,使其成為可直接 JSON.parse 的 JSON 陣列。 +只回傳修正後的 JSON 陣列內容,不要使用 markdown code fence,不要加任何解釋。 +如果原內容不是陣列,也請盡量修成合理的 JSON 陣列;若無法判斷,回傳 []。`; + const userContent = `檔案: ${label}\n原始內容:\n${rawText}`; + const repaired = await chat(systemPrompt, userContent); + return stripCodeFence(repaired); +} + +/** + * 驗證 JSON 陣列檔案是否存在且格式正確。 + * 若格式錯誤,直接嘗試透過 AI 修復,修復後再次檢查; + * 第二次檢查仍失敗才丟出例外。 + * 若檔案不存在,回傳 exists=false,交由呼叫端決定是否補檔。 + */ +export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + + if (!fs.existsSync(fullPath)) { + console.log(` ⚠️ ${label} 不存在,將於驗證後補建`); + return { exists: false, valid: false, repaired: false }; + } + + try { + JSON.parse(fs.readFileSync(fullPath, 'utf8')); + console.log(` ✅ ${label} JSON 格式正確`); + return { exists: true, valid: true, repaired: false }; + } catch (e) { + console.error(` ❌ ${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`); + try { + const original = fs.readFileSync(fullPath, 'utf8'); + const repaired = await repairer(fullPath, label, original); + fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8'); + JSON.parse(fs.readFileSync(fullPath, 'utf8')); + console.log(` ✅ ${label} 已由 AI 修正並通過再次驗證`); + return { exists: true, valid: true, repaired: true }; + } catch (repairErr) { + console.error(` ❌ ${label} 修正失敗: ${repairErr.message}`); + throw repairErr; + } + } +} + +/** + * 若檔案不存在則建立空陣列。 + */ +export function ensureJSONArrayFileExists(fullPath, label) { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + if (fs.existsSync(fullPath)) return false; + + fs.writeFileSync(fullPath, '[]\n', 'utf8'); + console.log(` ⚠️ ${label} 不存在,已建立空陣列`); + return true; +} diff --git a/app/json.test.js b/app/json.test.js new file mode 100644 index 0000000..176d47e --- /dev/null +++ b/app/json.test.js @@ -0,0 +1,61 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; + +describe('json helpers', () => { + let workspace; + + beforeEach(() => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'json-test-')); + }); + + afterEach(() => { + fs.rmSync(workspace, { recursive: true, force: true }); + }); + + it('reports missing file without creating it', async () => { + const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); + + const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json'); + + assert.deepEqual(result, { exists: false, valid: false, repaired: false }); + assert.equal(fs.existsSync(fullPath), false); + }); + + it('creates an empty array file when asked to ensure existence', () => { + const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); + + const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json'); + + assert.equal(created, true); + assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n'); + }); + + it('keeps a valid JSON array unchanged', async () => { + const fullPath = path.join(workspace, '.gitea/ai-review/exclusions.json'); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, '[]\n', 'utf8'); + + const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/exclusions.json'); + + assert.deepEqual(result, { exists: true, valid: true, repaired: false }); + assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n'); + }); + + it('repairs invalid JSON using AI output and rewrites the file', async () => { + const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, '{broken', 'utf8'); + + const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async (_fullPath, _label, original) => { + assert.equal(original, '{broken'); + return '[{"fixed":true}]'; + }); + + assert.deepEqual(result, { exists: true, valid: true, repaired: true }); + assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n'); + }); +}); diff --git a/app/main.js b/app/main.js index c51e4ee..ae25281 100644 --- a/app/main.js +++ b/app/main.js @@ -1,4 +1,3 @@ -import fs from 'fs'; import path from 'path'; import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { loadRoles, getRoleIntro } from './roles.js'; @@ -6,6 +5,7 @@ import { getPRDiff, postComment } from './gitea.js'; import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js'; import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { cloneRepo, commitAndPush } from './git.js'; +import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; @@ -100,29 +100,21 @@ async function main() { // Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON console.log('\n🔎 Step6: JSON 格式驗證'); + const missingPaths = []; for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { const fullPath = path.join(repoDir || WORKSPACE, relPath); - if (!fs.existsSync(fullPath)) { - console.log(` ⚠️ ${relPath} 不存在,跳過驗證`); - continue; - } try { - JSON.parse(fs.readFileSync(fullPath, 'utf8')); - console.log(` ✅ ${relPath} JSON 格式正確`); - } catch (e) { - console.error(` ❌ ${relPath} JSON 格式錯誤: ${e.message},嘗試修正...`); - try { - const backupPath = fullPath + '.bak'; - fs.copyFileSync(fullPath, backupPath); - fs.writeFileSync(fullPath, '[]\n', 'utf8'); - console.log(` ✅ ${relPath} 已重置為空陣列(原檔備份至 ${relPath}.bak)`); - } catch (repairErr) { - console.error(` ❌ ${relPath} 修正失敗: ${repairErr.message}`); - process.exit(1); - } + const result = await validateJSONArrayFile(fullPath, relPath); + if (!result.exists) missingPaths.push({ fullPath, relPath }); + } catch { + process.exit(1); } } + for (const { fullPath, relPath } of missingPaths) { + ensureJSONArrayFileExists(fullPath, relPath); + } + // Step7: commit/push findings.json 到來源分支 console.log('\n💾 Step7: 記憶區 Commit/Push'); await commitAndPush(WORKSPACE, repoDir);