feat(ai-review 對話收斂): 讀 PR review 留言判斷解決狀態並收斂 findings #45
@@ -307,6 +307,22 @@ describe('postFindingsReview', () => {
|
||||
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 () => {
|
||||
const reviewCalls = [];
|
||||
await postFindingsReview([
|
||||
|
||||
+57
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
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';
|
||||
|
||||
describe('findings exclusions', () => {
|
||||
@@ -59,6 +59,18 @@ describe('findings exclusions', () => {
|
||||
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', () => {
|
||||
const repoRoot = path.join(workspace, 'repo');
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
@@ -225,6 +237,50 @@ describe('findings exclusions', () => {
|
||||
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', () => {
|
||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
+24
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
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 = `---
|
||||
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', () => {
|
||||
it('renders a table row per role with its badge', () => {
|
||||
const intro = getRoleIntro([parseRoleFile(SAMPLE)]);
|
||||
|
||||
Reference in New Issue
Block a user