diff --git a/app/comments.test.js b/app/comments.test.js index 06b3fbc..e1c0792 100644 --- a/app/comments.test.js +++ b/app/comments.test.js @@ -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([ diff --git a/app/findings.test.js b/app/findings.test.js index 28bff17..a15ae03 100644 --- a/app/findings.test.js +++ b/app/findings.test.js @@ -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 }); diff --git a/app/roles.test.js b/app/roles.test.js index d06a502..80d7335 100644 --- a/app/roles.test.js +++ b/app/roles.test.js @@ -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)]);