144 lines
6.1 KiB
JavaScript
144 lines
6.1 KiB
JavaScript
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');
|
||
|
||
/**
|
||
* 解析單一角色 .md 檔:前置 YAML frontmatter(徽章、代表色、面向、個性等)+ 本文(審查重點)。
|
||
* 回傳合併後的角色物件:{ name, side, focus, badge, color, personality, 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;
|
||
|
||
/**
|
||
* 讀取並解析所有角色 .md,結果快取於模組層級(單次程序生命週期內檔案不變)。
|
||
* 單一檔案解析失敗(壞 YAML、缺 frontmatter 等)時記錄警告並略過,不讓整個流程崩潰。
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 載入攻擊方角色(Step3 產生 findings 用),依檔名排序。
|
||
* 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。
|
||
*/
|
||
export function loadRoles() {
|
||
return readRoleFiles().filter(r => r.side === 'attack');
|
||
}
|
||
|
||
/** 依 frontmatter name 取得單一角色(不分大小寫),找不到回傳 null。 */
|
||
export function loadRole(name) {
|
||
const target = String(name).toLowerCase();
|
||
return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null;
|
||
}
|
||
|
||
/**
|
||
* 由角色定義組出攻擊方的 system prompt:
|
||
* 套用其個性與審查重點本文,並要求以固定 JSON 陣列格式回傳 findings。
|
||
*/
|
||
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:
|
||
* 當該角色先前提出的問題只有檔名、缺行號時,請它對照 Git Diff 找出實際行號。
|
||
*/
|
||
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:
|
||
* 套用其個性與裁決準則本文,要求對一條 finding 判定成立或誤報,回固定 JSON 物件。
|
||
* role 為 null 時退回不帶角色的通用裁判 prompt。
|
||
*/
|
||
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');
|
||
}
|
||
|
||
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');
|
||
}
|