Files
ai-code-review/app/roles.js
T

218 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 純字串處理,無任何檔案 IOfrontmatter 中若自帶 `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<ReturnType<typeof parseRoleFile>>} 已解析的角色物件陣列(依檔名排序)。
*
* @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<ReturnType<typeof parseRoleFile>>} 攻擊方角色物件陣列。
*
* @remarks 透過 `readRoleFiles` 取得快取後的全部角色再過濾,首次呼叫會觸發檔案讀取。
*/
export function loadRoles() {
return readRoleFiles().filter(r => r.side === 'attack');
}
/**
* 依 frontmatter `name` 取得單一角色(比對不分大小寫),找不到回傳 `null`。
*
* 不分攻擊方 / 防守方,所有已成功載入的角色皆可查得。
*
* @param {string} name - 角色名稱(大小寫不拘)。
* @returns {ReturnType<typeof parseRoleFile> | 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<typeof parseRoleFile>} 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<typeof parseRoleFile> | 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<typeof parseRoleFile> | 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<ReturnType<typeof parseRoleFile>>} 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');
}