import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import yaml from 'js-yaml'; import { warn } from './log.js'; const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles'); /** * 解析單一角色 Markdown 檔內容,拆出前置 YAML frontmatter 與本文。 * * 會先將 CRLF 正規化為 LF,再以 `---` 分隔線切出 frontmatter(徽章、代表色、 * 面向、個性等欄位)與其後的本文(審查重點 / 裁決準則)。frontmatter 欄位會 * 被攤平到回傳物件,本文則放入 `body`(已去除頭尾空白)。 * * @param {string} content - 角色 `.md` 檔的完整文字內容。 * @returns {{ name?: string, side?: string, focus?: string, badge?: string, * color?: string, personality?: string, body: string, * [key: string]: unknown }} 合併 frontmatter 與本文後的角色物件。 * @throws {Error} 當內容缺少合法 `---` frontmatter 區塊時拋出「角色檔缺少 frontmatter」。 * @throws {import('js-yaml').YAMLException} 當 frontmatter 不是合法 YAML 時(由 `yaml.load` 拋出,未攔截)。 * * @remarks 純字串處理,無任何檔案 IO;frontmatter 中若自帶 `body` 欄位會被本文覆蓋。 */ export function parseRoleFile(content) { const normalized = content.replace(/\r\n/g, '\n'); const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) throw new Error('角色檔缺少 frontmatter'); const meta = yaml.load(match[1]) || {}; return { ...meta, body: match[2].trim() }; } let cachedRoles = null; /** * 讀取並解析 `ROLES_DIR` 下所有角色 `.md` 檔,依檔名排序後回傳角色陣列。 * * 結果快取於模組層級(`cachedRoles`),同一程序生命週期內只讀檔一次;之後即使 * 角色檔有變動也不會重新載入,需重啟程序才會生效。單一檔案解析失敗(壞 YAML、 * 缺 frontmatter 等)只記錄警告並略過,不會中斷其他角色的載入。 * * @returns {Array>} 已解析的角色物件陣列(依檔名排序)。 * * @remarks 模組私有函式;使用同步檔案 IO。目錄不存在或無權限時,`fs.readdirSync` * 會在容錯範圍外拋出錯誤。 */ function readRoleFiles() { if (cachedRoles) return cachedRoles; const roles = []; for (const f of fs.readdirSync(ROLES_DIR).filter(f => f.endsWith('.md')).sort()) { try { roles.push(parseRoleFile(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8'))); } catch (e) { warn(`角色檔解析失敗,已略過: ${f}(${e.message})`); } } cachedRoles = roles; return cachedRoles; } /** * 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。 * * 供 Step3 產生 findings 階段使用。防守方角色(如 Paladin)不在回傳之列, * 其裁決邏輯由去重 / 誤報過濾流程處理。 * * @returns {Array>} 攻擊方角色物件陣列。 * * @remarks 透過 `readRoleFiles` 取得快取後的全部角色再過濾,首次呼叫會觸發檔案讀取。 */ export function loadRoles() { return readRoleFiles().filter(r => r.side === 'attack'); } /** * 依 frontmatter `name` 取得單一角色(比對不分大小寫),找不到回傳 `null`。 * * 不分攻擊方 / 防守方,所有已成功載入的角色皆可查得。 * * @param {string} name - 角色名稱(大小寫不拘)。 * @returns {ReturnType | null} 對應角色物件,無對應時為 `null`。 * * @remarks 透過 `readRoleFiles` 取得快取角色清單,首次呼叫會觸發檔案讀取。 */ export function loadRole(name) { const target = String(name).toLowerCase(); return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null; } /** * 由攻擊方角色定義組出其分析用 system prompt。 * * 套用角色的徽章、名稱、面向(focus,缺省為「綜合」)、個性(personality,可選) * 與審查重點本文(body),並附上固定指示:分析 Git Diff 僅針對新增/修改處找問題, * 並以固定 JSON 陣列格式(level / role / location / problem / suggestion)回傳 findings, * 強制每條問題帶 `檔案路徑:行號`。 * * @param {ReturnType} role - 攻擊方角色物件(需含 `name`、`body`;`badge`/`focus`/`personality` 可選)。 * @returns {string} 組裝完成的多行 system prompt 文字。 * @throws {TypeError} 當 `role` 為 `null`/`undefined` 時(未做防呆,存取屬性即拋出)。 * * @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。 */ export function buildAnalysisPrompt(role) { return [ `你是 ${role.badge ? role.badge + ' ' : ''}${role.name},負責「${role.focus || '綜合'}」面向的程式碼審查(攻擊方)。`, role.personality ? `個性:${role.personality}` : '', '', role.body, '', '---', '', '請分析以下 Git Diff,只針對新增/修改處,依你的面向找出所有問題。', '回傳 JSON 陣列,每個問題格式如下:', '{', ' "level": "critical|warning|info",', ` "role": "${role.name}",`, ' "location": "檔案路徑:行號(行號為必填,例如 app/foo.js:42)",', ' "problem": "繁體中文(台灣用語)說明審查員認為這裡有問題的原因,不要只填檔案路徑或行號",', ' "suggestion": "繁體中文(台灣用語)的具體修改建議"', '}', '', '等級定義:', '- critical:嚴重且應立即處理的問題', '- warning:建議修正的問題', '- info:可選的改善建議', '', 'location 規則(務必遵守):', '- **每一條問題都必須帶行號**,格式一律為 `檔案路徑:行號`(單一行號,例如 `app/foo.js:42`)。', '- 嚴禁只給檔名而省略行號;行號請取該問題在 Git Diff 新增/修改處的實際行號。', '- 一條問題只對應一個檔案與一個行號,不要用逗號列多個檔案。', '', '只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。', ].filter(l => l !== '').join('\n'); } /** * 組出「補行號」用的 system prompt。 * * 用於某角色先前提出的 finding 其 `location` 只有檔名、缺行號的情境:請 LLM 對照 * 該檔 Git Diff 找出問題對應的實際行號,並只回 `{"line": 數字}`(找不到回 `{"line": 0}`)。 * * @param {ReturnType | null | undefined} [role] - 角色物件;可省略或為 null, * 此時名稱退回 `'AI Review'` 且不帶徽章與面向子句。 * @returns {string} 組裝完成的多行 system prompt 文字。 * * @remarks 使用選擇性串接(`?.`),對 `role` 為空值具防呆,不會拋出例外。 */ export function buildLocateLinePrompt(role) { const name = role?.name || 'AI Review'; const badge = role?.badge ? `${role.badge} ` : ''; return [ `你是 ${badge}${name}${role?.focus ? `(負責「${role.focus}」面向)` : ''}。`, '你先前提出了一個問題,但 location 只給了檔名、沒有行號。請對照下方提供的該檔案 Git Diff,找出這個問題對應的**實際行號**(新增/修改處在該檔案中的行號)。', '只回傳 JSON 物件:{"line": 數字},不要有其他文字。若 diff 中確實找不到對應行,回傳 {"line": 0}。', ].join('\n'); } /** * 由防守方角色定義組出「單條 finding 誤報裁決」用的 system prompt。 * * `role` 存在時套用其徽章、名稱、面向(focus,缺省「裁決」)、個性與裁決準則本文(body); * `role` 為空值時退回固定的通用裁判 persona(🛡️ Paladin 聖騎士)。prompt 要求對一條 * 攻擊方 finding 判定「成立 / 誤報」,並只回 `{"verdict", "reason"}`;無法確定時一律回 * `"confirmed"`(寧可保留、不冤枉)。 * * @param {ReturnType | null | undefined} role - 防守方角色物件;為空值時改用通用裁判 persona。 * @param {string} [exclusionHint=''] - 額外的排除 / 已知誤報提示文字;為空字串時該行會被略過。 * @returns {string} 組裝完成的多行 system prompt 文字。 * * @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。 */ export function buildVerdictPrompt(role, exclusionHint = '') { const persona = role ? [ `你是 ${role.badge ? role.badge + ' ' : ''}${role.name},負責「${role.focus || '裁決'}」的程式碼審查裁決(防守方)。`, role.personality ? `個性:${role.personality}` : '', '', role.body, ] : ['你是 🛡️ Paladin(聖騎士),公正的裁判。不冤枉無辜的程式碼,也不放水。']; return [ ...persona, '', '---', '', '以下提供一條攻擊方的 finding(JSON)。請依你的裁決準則與原始碼脈絡,判斷它是「成立」還是「誤報/不適用」(例如:已正確使用 secrets、CI/CD 必要權限、他處已妥善處理、語義其實正確)。', exclusionHint, '只回傳 JSON 物件:{"verdict": "confirmed" | "false_positive", "reason": "繁體中文(台灣用語)理由"},不要有其他文字。無法確定時一律回 "confirmed"(不冤枉、寧可保留)。', ].filter(l => l !== '').join('\n'); } /** * 由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格。 * * 表格含三欄:角色(粗體,含徽章)、面向(focus)、個性(personality);缺省欄位以空字串呈現。 * 通常用於 PR 留言 / 審查報告開頭呈現參與審查的角色陣容。 * * @param {Array>} roles - 角色物件陣列(每個可含 `badge`/`name`/`focus`/`personality`)。 * @returns {string} Markdown 格式的多行表格字串。 * @throws {TypeError} 當 `roles` 非可迭代值(如 `null`/`undefined`)時,`for...of` 會拋出。 * * @remarks 純字串組裝,無副作用;傳入空陣列會得到只有標題與表頭的表格。 */ export function getRoleIntro(roles) { const lines = [ '## 🤖 AI Code Review 團隊', '', '| 👤 角色 | 🎯 面向 | 🧠 個性 |', '|--------|--------|--------|', ]; for (const r of roles) { const badge = r.badge ? `${r.badge} ` : ''; lines.push(`| **${badge}${r.name}** | ${r.focus || ''} | ${r.personality || ''} |`); } return lines.join('\n'); }