feat(ai-review 對話收斂): 讀 PR review 留言判斷解決狀態並收斂 findings #45

Merged
admin merged 69 commits from develop into master 2026-06-23 08:30:28 +00:00
3 changed files with 97 additions and 2 deletions
Showing only changes of commit d03f08e19d - Show all commits
+16
View File
@@ -307,6 +307,22 @@ describe('postFindingsReview', () => {
assert.equal(reviewCalls[0].body, reviewCalls[0].body.trimEnd()); assert.equal(reviewCalls[0].body, reviewCalls[0].body.trimEnd());
}); });
it('counts both new and old findings in the summary but only inline-comments new ones', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'old crit', is_new: false },
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'new warn', is_new: true },
{ level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'new info', is_new: true },
], { postReview: async (a) => { reviewCalls.push(a); } });
const body = reviewCalls[0].body;
assert.match(body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \| 0 筆 \|/);
assert.match(body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \| 0 筆 \|/);
// 舊問題 app/a.js 不產生行內 comment;只有新問題被標註
assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
assert.deepEqual(reviewCalls[0].comments.map(c => c.path), ['app/b.js', 'app/c.js']);
});
it('separates old and new findings in default review statistics', async () => { it('separates old and new findings in default review statistics', async () => {
const reviewCalls = []; const reviewCalls = [];
await postFindingsReview([ await postFindingsReview([
+57 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions } from './findings.js'; import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers } from './findings.js';
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js'; import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
describe('findings exclusions', () => { describe('findings exclusions', () => {
@@ -59,6 +59,18 @@ describe('findings exclusions', () => {
assert.equal(merged.length, 2); assert.equal(merged.length, 2);
}); });
it('appendExclusions keeps same-path entries that have different original text', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify([{ location: 'app/a.js:1', original_finding: '問題甲' }], null, 2));
appendExclusions(workspace, [{ location: 'app/a.js:5', original_finding: '問題乙', reason: 'r' }]);
const onDisk = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
assert.equal(onDisk.length, 2); // 同檔但原文不同 → 視為不同排除條目,兩者皆保留
assert.deepEqual(onDisk.map(e => e.original_finding), ['問題甲', '問題乙']);
});
it('writes appended exclusions to both workspace and mirror dir', () => { it('writes appended exclusions to both workspace and mirror dir', () => {
const repoRoot = path.join(workspace, 'repo'); const repoRoot = path.join(workspace, 'repo');
fs.mkdirSync(repoRoot, { recursive: true }); fs.mkdirSync(repoRoot, { recursive: true });
@@ -225,6 +237,50 @@ describe('findings exclusions', () => {
assert.equal(result.length, 2); assert.equal(result.length, 2);
}); });
it('keeps a finding when the defender returns an out-of-range verdict value', async () => {
const findings = [{ level: 'warning', role: 'Mage', location: 'a.js:1', problem: 'p', suggestion: 's' }];
const chatFn = async () => ({ verdict: 'maybe', reason: 'x' }); // 非 confirmed/false_positive
const result = await filterFalsePositivesWithAI(findings, [], chatFn);
assert.equal(result.length, 1); // 只有明確 false_positive 才剔除,其餘保守保留
});
it('resolveMissingLineNumbers fills missing line numbers by re-asking the role', async () => {
const findings = [
{ level: 'critical', role: 'Maya', location: 'app/a.js', problem: 'p', suggestion: 's' },
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', problem: 'p', suggestion: 's' }, // 已有行號 → 不動
];
let calls = 0;
const chatFn = async () => { calls += 1; return { line: 42 }; };
await resolveMissingLineNumbers(findings, 'diff --git a/app/a.js b/app/a.js\n@@ -1 +1 @@', { chatFn, getRole: () => ({ name: 'Maya' }) });
assert.equal(findings[0].location, 'app/a.js:42'); // 補上行號
assert.equal(findings[1].location, 'app/b.js:20'); // 不變
assert.equal(calls, 1); // 只對缺行號者呼叫
});
it('resolveMissingLineNumbers retries until a valid line appears', async () => {
const findings = [{ level: 'warning', role: 'Leo', location: 'app/x.js', problem: 'p', suggestion: 's' }];
let n = 0;
const chatFn = async () => { n += 1; return n < 3 ? { line: 0 } : { line: 7 }; };
await resolveMissingLineNumbers(findings, 'd', { chatFn, getRole: () => null, maxAttempts: 5 });
assert.equal(findings[0].location, 'app/x.js:7');
assert.equal(n, 3); // 第三次才給出有效行號
});
it('resolveMissingLineNumbers keeps the filename after exhausting retries', async () => {
const findings = [{ level: 'warning', role: 'Leo', location: 'app/y.js', problem: 'p', suggestion: 's' }];
let n = 0;
const chatFn = async () => { n += 1; return { line: 0 }; };
await resolveMissingLineNumbers(findings, 'd', { chatFn, getRole: () => null, maxAttempts: 3 });
assert.equal(findings[0].location, 'app/y.js'); // 仍保留檔名
assert.equal(n, 3); // 嘗試 3 次後放棄
});
it('logs exclusions file metadata and repo state when loading exclusions', () => { it('logs exclusions file metadata and repo state when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseRoleFile, loadRoles, loadRole, buildAnalysisPrompt, getRoleIntro } from './roles.js'; import { parseRoleFile, loadRoles, loadRole, buildAnalysisPrompt, buildLocateLinePrompt, getRoleIntro } from './roles.js';
const SAMPLE = `--- const SAMPLE = `---
name: Tester name: Tester
@@ -81,6 +81,29 @@ describe('buildAnalysisPrompt', () => {
}); });
}); });
describe('buildAnalysisPrompt 行號要求', () => {
it('requires a line number in location', () => {
const prompt = buildAnalysisPrompt(parseRoleFile(SAMPLE));
assert.match(prompt, /行號為必填/);
assert.match(prompt, /每一條問題都必須帶行號/);
});
});
describe('buildLocateLinePrompt', () => {
it('asks the same role to return a JSON line number', () => {
const prompt = buildLocateLinePrompt({ name: 'Maya', badge: '🧪', focus: 'testing' });
assert.match(prompt, /Maya/);
assert.match(prompt, /找出.*行號|實際行號/);
assert.match(prompt, /\{"line": 數字\}/);
});
it('tolerates a bare role object without badge/focus', () => {
const prompt = buildLocateLinePrompt({ name: 'Leo' });
assert.match(prompt, /Leo/);
assert.doesNotMatch(prompt, /undefined/);
});
});
describe('getRoleIntro', () => { describe('getRoleIntro', () => {
it('renders a table row per role with its badge', () => { it('renders a table row per role with its badge', () => {
const intro = getRoleIntro([parseRoleFile(SAMPLE)]); const intro = getRoleIntro([parseRoleFile(SAMPLE)]);