From 222de4b369e983fe786447e3daec8090998238a6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 09:39:11 +0000 Subject: [PATCH] feat: enhance findings and exclusions handling with repo state logging --- README.md | 1 + app/findings.js | 39 ++++++++++++++++++++++++++++----- app/findings.test.js | 52 ++++++++++++++++++++++++++++++++++++++++++-- app/git.js | 17 +++++++++++++++ app/main.js | 8 +++++-- 5 files changed, 107 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b61d00f..c29b486 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ 7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼 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 用量 +10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題 # 使用說明 diff --git a/app/findings.js b/app/findings.js index 41db736..995a9ef 100644 --- a/app/findings.js +++ b/app/findings.js @@ -40,11 +40,24 @@ function normalizeExclusions(data) { return []; } +function formatFileTime(mtimeMs) { + if (!Number.isFinite(mtimeMs)) return 'unknown'; + return new Date(mtimeMs).toISOString(); +} + /** * 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH) */ export function loadOldFindings(workspace) { - const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false })); + const fullPath = path.join(workspace, FINDINGS_PATH); + const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false })); + if (fs.existsSync(fullPath)) { + const stat = fs.statSync(fullPath); + console.log(` 讀取舊 findings 檔案: ${fullPath}`); + console.log(` 舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`); + } else { + console.log(` 舊 findings 檔案不存在: ${fullPath}`); + } console.log(` 讀取舊 findings: ${old.length} 筆`); return old; } @@ -112,23 +125,37 @@ export async function deduplicateWithAI(findings) { /** * 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH) */ -export function loadExclusions(workspace) { +export function loadExclusions(workspace, repoState = null) { const fullPath = path.join(workspace, EXCLUSIONS_PATH); if (!fs.existsSync(fullPath)) { - console.log(' 排除問題檔案不存在,視為空'); - console.log(' 讀取排除問題: 0 筆'); + console.log(` 排除問題檔案不存在,視為空: ${fullPath}`); + if (repoState) { + const branch = repoState.branch || 'detached'; + const shortSha = repoState.shortSha || repoState.headSha || 'unknown'; + console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`); + } + console.log(' 讀取排除問題: raw=0 normalized=0 筆'); return []; } let exclusions = []; + let rawCount = 0; try { + const stat = fs.statSync(fullPath); const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + rawCount = Array.isArray(data) ? data.length : Array.isArray(data?.excluded_findings) ? data.excluded_findings.length : 0; exclusions = normalizeExclusions(data); + const branch = repoState?.branch || 'detached'; + const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown'; + const commitTime = repoState?.commitTime || 'unknown'; + console.log(` 讀取排除問題檔案: ${fullPath}`); + console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`); + console.log(` 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`); } catch (e) { - console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空`); + console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空: ${fullPath}`); exclusions = []; } - console.log(` 讀取排除問題: ${exclusions.length} 筆`); + console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`); return exclusions; } diff --git a/app/findings.test.js b/app/findings.test.js index d163a2c..7b639b0 100644 --- a/app/findings.test.js +++ b/app/findings.test.js @@ -3,17 +3,25 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { loadExclusions, applyExclusions } from './findings.js'; -import { EXCLUSIONS_PATH } from './config.js'; +import { loadOldFindings, loadExclusions, applyExclusions } from './findings.js'; +import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js'; describe('findings exclusions', () => { let workspace; + let logs; + let originalLog; beforeEach(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'findings-test-')); + logs = []; + originalLog = console.log; + console.log = (...args) => { + logs.push(args.join(' ')); + }; }); afterEach(() => { + console.log = originalLog; fs.rmSync(workspace, { recursive: true, force: true }); }); @@ -47,4 +55,44 @@ describe('findings exclusions', () => { assert.equal(filtered.length, 1); assert.equal(filtered[0].location, 'README.md:12'); }); + + it('logs exclusions file metadata and repo state when loading exclusions', () => { + const fullPath = path.join(workspace, EXCLUSIONS_PATH); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, JSON.stringify([ + { location: 'entrypoint.sh:180', suggestion: 'ignore' }, + { location: 'README.md:12', suggestion: 'ignore' }, + ], null, 2)); + + const repoState = { + branch: 'feat/test', + shortSha: 'abc1234', + commitTime: '2026-05-15T09:29:49.817Z', + repoDir: path.join(workspace, 'repo'), + }; + + const exclusions = loadExclusions(workspace, repoState); + + assert.equal(exclusions.length, 2); + assert.ok(logs.some(line => line.includes(`讀取排除問題檔案: ${fullPath}`))); + assert.ok(logs.some(line => line.includes('來源分支狀態: branch=feat/test commit=abc1234'))); + assert.ok(logs.some(line => line.includes('raw=2 normalized=2'))); + assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`))); + }); + + it('logs findings file metadata when loading old findings', () => { + const fullPath = path.join(workspace, FINDINGS_PATH); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, JSON.stringify([ + { level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' }, + ], null, 2)); + + const findings = loadOldFindings(workspace); + + assert.equal(findings.length, 1); + assert.equal(findings[0].is_new, false); + assert.ok(logs.some(line => line.includes(`讀取舊 findings 檔案: ${fullPath}`))); + assert.ok(logs.some(line => line.includes('舊 findings 檔案資訊: bytes='))); + assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`))); + }); }); diff --git a/app/git.js b/app/git.js index 8cda213..e2da47b 100644 --- a/app/git.js +++ b/app/git.js @@ -41,6 +41,23 @@ function withAskpass(workspace, fn) { } } +function readGitOutput(run, args, cwd, env) { + try { + return run(args, cwd, env); + } catch { + return ''; + } +} + +export function getRepoState(repoDir, _spawnSync = spawnSync) { + const run = makeRunner(_spawnSync); + const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir); + const shortSha = readGitOutput(run, ['rev-parse', '--short', 'HEAD'], repoDir); + const branch = readGitOutput(run, ['branch', '--show-current'], repoDir); + const commitTime = readGitOutput(run, ['show', '-s', '--format=%cI', 'HEAD'], repoDir); + return { repoDir, branch, headSha, shortSha, commitTime }; +} + /** * Clone PR head branch to workspace/repo (idempotent) */ diff --git a/app/main.js b/app/main.js index 1850642..7d7a568 100644 --- a/app/main.js +++ b/app/main.js @@ -4,7 +4,7 @@ import { loadRoles, getRoleIntro } from './roles.js'; 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 { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; @@ -69,6 +69,10 @@ async function main() { } catch (e) { console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`); } + const repoState = repoDir ? getRepoState(repoDir) : null; + if (repoState) { + console.log(` repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`); + } const oldFindings = loadOldFindings(repoDir || WORKSPACE); const mergedFindings = mergeFindings(oldFindings, newFindings); console.log(` Step3 merged findings total=${mergedFindings.length}`); @@ -81,7 +85,7 @@ async function main() { // Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報 console.log('\n🚫 Step4: AI 排除問題過濾'); // 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考 - const exclusions = loadExclusions(repoDir || WORKSPACE); + const exclusions = loadExclusions(repoDir || WORKSPACE, repoState); const ruleFiltered = applyExclusions(sorted, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); console.log(` Step4 完成: findings total=${filtered.length}`);