This repository has been archived on 2026-09-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
ai-code-review/app/test/findings.test.js
T
JefferyandClaude Opus 4.8 775cc575ae test(app): 補齊 isSafeRepoPath/extractBalancedJSON/normalizeText/repairJSONArrayWithAI/格式化函式測試
為可測性 export 三個內部函式(isSafeRepoPath、extractBalancedJSON/extractJSONText、normalizeText)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:41:41 +08:00

396 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { describe, it, beforeEach, afterEach } from 'node:test';
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, resolveMissingLineNumbers, normalizeText } from '../findings.js';
import { EXCLUSIONS_PATH, FINDINGS_PATH } from '../config.js';
describe('findings exclusions', () => {
let workspace;
let logs;
let originalLog;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'findings-test-'));
logs = [];
originalLog = console.log;
console.log = (...args) => {
logs.push(args.join(' '));
};
});
afterEach(() => {
console.log = originalLog;
fs.rmSync(workspace, { recursive: true, force: true });
});
it('loads excluded_findings wrapper format', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify({
excluded_findings: [
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
],
}, null, 2));
const exclusions = loadExclusions(workspace);
assert.equal(exclusions.length, 1);
assert.equal(exclusions[0].location, 'entrypoint.sh:180');
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
});
it('appends new exclusion entries and dedupes by file + 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));
const merged = appendExclusions(workspace, [
{ location: 'app/a.js:9', original_finding: '既有誤報', reason: '行號不同但同檔同原文 → 視為重複' },
{ location: 'app/b.js:5', original_finding: '新誤報', reason: '誤報' },
]);
const onDisk = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
assert.equal(onDisk.length, 2); // 1 既有 + 1 新增(重複者略過)
assert.deepEqual(onDisk.map(e => e.location), ['app/a.js:1', 'app/b.js:5']);
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 });
appendExclusions(workspace, [{ location: 'app/x.js:3', original_finding: '誤報X', reason: 'r' }], repoRoot);
const ws = JSON.parse(fs.readFileSync(path.join(workspace, EXCLUSIONS_PATH), 'utf8'));
const mirror = JSON.parse(fs.readFileSync(path.join(repoRoot, EXCLUSIONS_PATH), 'utf8'));
assert.equal(ws[0].location, 'app/x.js:3');
assert.deepEqual(mirror, ws);
});
it('returns null and writes nothing when there are no new entries', () => {
assert.equal(appendExclusions(workspace, []), null);
assert.ok(!fs.existsSync(path.join(workspace, EXCLUSIONS_PATH)));
});
it('repairs exclusions wrapper format to a top-level array', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify({
exclusions: [
{ location: 'README.md:12', suggestion: 'keep' },
],
}, null, 2));
const exclusions = loadExclusions(workspace);
const repaired = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
assert.equal(exclusions.length, 1);
assert.ok(Array.isArray(repaired));
assert.equal(repaired[0].location, 'README.md:12');
assert.equal(repaired[0].suggestion, 'keep');
assert.ok(logs.some(line => line.includes('排除問題格式已修正為頂層陣列: source=exclusions -> array')));
});
it('mirrors repaired exclusions into the workspace root when requested', () => {
const repoRoot = path.join(workspace, 'repo');
const mirrorRoot = path.join(workspace, 'workspace');
const repoFullPath = path.join(repoRoot, EXCLUSIONS_PATH);
const mirrorFullPath = path.join(mirrorRoot, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(repoFullPath), { recursive: true });
fs.mkdirSync(path.dirname(mirrorFullPath), { recursive: true });
fs.writeFileSync(repoFullPath, JSON.stringify({
exclusions: [
{ location: 'README.md:12', suggestion: 'keep' },
],
}, null, 2));
const exclusions = loadExclusions(repoRoot, null, mirrorRoot);
const mirror = JSON.parse(fs.readFileSync(mirrorFullPath, 'utf8'));
assert.equal(exclusions.length, 1);
assert.ok(Array.isArray(mirror));
assert.equal(mirror[0].location, 'README.md:12');
assert.equal(mirror[0].suggestion, 'keep');
});
it('applies exclusions loaded from wrapper format', () => {
const findings = [
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
{ location: 'README.md:12', role: 'Maya', suggestion: 'keep' },
];
const exclusions = [
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
];
const filtered = applyExclusions(findings, exclusions);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].location, 'README.md:12');
});
it('dedupes repeated exclusions when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify([
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
{ location: 'entrypoint.sh:999', title: 'fetch_package_versions jq overhead' },
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
], null, 2));
const exclusions = loadExclusions(workspace);
assert.equal(exclusions.length, 1);
assert.equal(exclusions[0].filePath, 'entrypoint.sh');
assert.equal(exclusions[0].text, 'fetch_package_versions jq overhead');
});
it('builds a compact exclusion hint for AI', async () => {
const findings = [
{ level: 'warning', role: 'Maya', location: 'src/app.cs:12', problem: '缺少測試驗證', suggestion: 'update tests' },
];
const exclusions = [
{ location: 'src/app.cs:1', original_finding: '更新套件後請補上測試驗證' },
{ location: 'src/app.cs:99', original_finding: '更新套件後請補上測試驗證 ' },
{ location: 'src/service.cs:3', original_finding: '更新套件後請補上測試驗證' },
{ location: 'src/service.cs:8', title: '請確認安全性變更' },
];
let capturedSystemPrompt = '';
let capturedUserContent = '';
const result = await filterFalsePositivesWithAI(findings, exclusions, async (systemPrompt, userContent) => {
capturedSystemPrompt = systemPrompt;
capturedUserContent = userContent;
return findings;
});
assert.equal(result.length, 1);
assert.ok(capturedSystemPrompt.includes('已知誤報清單(原始 4 筆,整理後 3 筆,分成 2 類)'));
assert.ok(capturedSystemPrompt.includes('更新套件後請補上測試驗證'));
assert.ok(capturedSystemPrompt.includes('paths=src/app.cs, src/service.cs'));
assert.ok(capturedSystemPrompt.includes('請確認安全性變更'));
assert.ok(capturedUserContent.includes('"location":"src/app.cs:12"'));
assert.ok(capturedUserContent.includes('"problem":"缺少測試驗證"'));
assert.ok(capturedUserContent.includes('"suggestion":"update tests"'));
});
it('judges each finding with a parallel defender sub-agent and drops only false positives', async () => {
const findings = [
{ level: 'critical', role: 'Assassin', location: 'a.js:1', problem: 'p1', suggestion: 's1' },
{ level: 'warning', role: 'Mage', location: 'b.js:2', problem: 'p2', suggestion: 's2' },
{ level: 'info', role: 'Bard', location: 'c.js:3', problem: 'p3', suggestion: 's3' },
];
const seenPrompts = [];
const chatFn = async (systemPrompt, userContent) => {
seenPrompts.push(systemPrompt);
const loc = JSON.parse(userContent).location;
return { verdict: loc === 'b.js:2' ? 'false_positive' : 'confirmed' };
};
const result = await filterFalsePositivesWithAI(findings, [], chatFn);
assert.deepEqual(result.map(f => f.location), ['a.js:1', 'c.js:3']); // b.js 誤報被剔除
assert.equal(seenPrompts.length, 3); // 每條 finding 各一個 sub-agent
assert.ok(seenPrompts.every(p => p.includes('Paladin'))); // 套用防守方角色
});
it('keeps a finding when its defender sub-agent call fails (conservative)', async () => {
const findings = [
{ level: 'critical', role: 'Assassin', location: 'a.js:1', problem: 'p', suggestion: 's' },
{ level: 'warning', role: 'Mage', location: 'b.js:2', problem: 'p', suggestion: 's' },
];
const chatFn = async (_s, userContent) => {
if (JSON.parse(userContent).location === 'a.js:1') throw new Error('LLM down');
return { verdict: 'false_positive' };
};
const result = await filterFalsePositivesWithAI(findings, [], chatFn);
assert.deepEqual(result.map(f => f.location), ['a.js:1']); // a 失敗→保守保留;b 誤報→剔除
});
it('keeps findings when the defender returns malformed verdicts (conservative)', async () => {
const findings = [
{ level: 'warning', role: 'Mage', location: 'a.js:1', problem: 'p', suggestion: 's' },
{ level: 'warning', role: 'Leo', location: 'b.js:2', problem: 'p', suggestion: 's' },
];
// 回傳 null / 無 verdict 欄位 / 非預期結構 → 皆非 false_positive,保守保留
const responses = [null, { foo: 'bar' }];
let i = 0;
const chatFn = async () => responses[i++ % responses.length];
const result = await filterFalsePositivesWithAI(findings, [], chatFn);
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('keeps failed and confirmed, drops only confirmed false positives (mixed parallel)', async () => {
const findings = [
{ level: 'warning', role: 'A', location: 'a.js:1', problem: 'p', suggestion: 'fail' },
{ level: 'warning', role: 'B', location: 'b.js:2', problem: 'p', suggestion: 'fp' },
{ level: 'warning', role: 'C', location: 'c.js:3', problem: 'p', suggestion: 'ok' },
];
const chatFn = async (_sys, user) => {
const loc = JSON.parse(user).location;
if (loc === 'a.js:1') throw new Error('boom'); // 失敗 → 保守保留
if (loc === 'b.js:2') return { verdict: 'false_positive' };// 誤報 → 剔除
return { verdict: 'confirmed' }; // 成立 → 保留
};
const result = await filterFalsePositivesWithAI(findings, [], chatFn);
assert.deepEqual(result.map(f => f.location).sort(), ['a.js:1', 'c.js:3']);
});
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('resolveMissingLineNumbers swallows chatFn exceptions and keeps the filename', async () => {
const findings = [{ level: 'warning', role: 'Leo', location: 'app/z.js', problem: 'p', suggestion: 's' }];
let n = 0;
const chatFn = async () => { n += 1; throw new Error('LLM down'); };
await resolveMissingLineNumbers(findings, 'd', { chatFn, getRole: () => null, maxAttempts: 2 });
assert.equal(findings[0].location, 'app/z.js'); // 例外被吞、保留檔名、不中斷流程
assert.equal(n, 2); // 每次嘗試仍呼叫、受上限約束
});
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 });
fs.writeFileSync(fullPath, JSON.stringify([
{ location: 'entrypoint.sh:180', suggestion: 'ignore' },
{ location: 'README.md:12', suggestion: 'ignore' },
], null, 2));
const repoState = {
branch: 'feat/test',
shortSha: 'abc1234',
commitTime: '2026-05-15T09:29:49.817Z',
repoDir: path.join(workspace, 'repo'),
};
const exclusions = loadExclusions(workspace, repoState);
assert.equal(exclusions.length, 2);
assert.ok(logs.some(line => line.includes(`讀取排除問題檔案: ${fullPath}`)));
assert.ok(logs.some(line => line.includes('來源分支狀態: branch=feat/test commit=abc1234')));
assert.ok(logs.some(line => line.includes('raw=2 normalized=2')));
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
});
it('logs findings file metadata when loading old findings', () => {
const fullPath = path.join(workspace, FINDINGS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify([
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
], null, 2));
const findings = loadOldFindings(workspace);
assert.equal(findings.length, 1);
assert.equal(findings[0].is_new, false);
assert.ok(logs.some(line => line.includes(`讀取舊 findings 檔案: ${fullPath}`)));
assert.ok(logs.some(line => line.includes('舊 findings 檔案資訊: bytes=')));
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
});
});
describe('normalizeText', () => {
it('normalizes full-width characters to the same result as half-width lowercase (NFKC)', () => {
// NFKC 將全形 ABC 折成半形 ABC,再小寫 → 'abc',與 'abc' 一致
assert.equal(normalizeText('ABC'), 'abc');
assert.equal(normalizeText('abc'), 'abc');
assert.equal(normalizeText('ABC'), normalizeText('abc'));
});
it('collapses mixed punctuation and symbols into single spaces', () => {
assert.equal(normalizeText('Hello, World!! foo@bar'), 'hello world foo bar');
});
it('collapses multiple spaces, newlines and tabs into a single space and trims', () => {
assert.equal(normalizeText(' multiple \n\t spaces \n here '), 'multiple spaces here');
});
it('lowercases uppercase input', () => {
assert.equal(normalizeText('UPPER Case Mix'), 'upper case mix');
});
it('returns empty string for non-string inputs', () => {
assert.equal(normalizeText(null), '');
assert.equal(normalizeText(undefined), '');
assert.equal(normalizeText(42), '');
assert.equal(normalizeText({ a: 1 }), '');
});
it('returns empty string for empty string input', () => {
assert.equal(normalizeText(''), '');
});
it('strips surrounding full-width whitespace and collapses CJK punctuation', () => {
// 全形空白被 trim、,與 !屬於 \p{P} 折成單一空白 → '你好 世界'
assert.equal(normalizeText(' 你好,世界! '), '你好 世界');
assert.equal(normalizeText('(重要)測試:項目#1'), '重要 測試 項目 1');
});
it('is idempotent', () => {
for (const input of ['ABC', 'Hello, World!! foo@bar', ' 你好,世界! ', 'UPPER Case Mix', '']) {
assert.equal(normalizeText(normalizeText(input)), normalizeText(input));
}
});
});