test(app): 補齊 isSafeRepoPath/extractBalancedJSON/normalizeText/repairJSONArrayWithAI/格式化函式測試

為可測性 export 三個內部函式(isSafeRepoPath、extractBalancedJSON/extractJSONText、normalizeText)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-06-26 14:41:41 +08:00
co-authored by Claude Opus 4.8
parent fc10ab7266
commit 775cc575ae
8 changed files with 354 additions and 5 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ function cleanText(value) {
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。 * @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。 * @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
*/ */
function normalizeText(value) { export function normalizeText(value) {
return cleanText(value) return cleanText(value)
.normalize('NFKC') .normalize('NFKC')
.toLowerCase() .toLowerCase()
+2 -2
View File
@@ -165,7 +165,7 @@ function stripOuterFence(text) {
* @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。 * @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。
* @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。 * @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。
*/ */
function extractBalancedJSON(text, startIndex) { export function extractBalancedJSON(text, startIndex) {
const source = String(text); const source = String(text);
const open = source[startIndex]; const open = source[startIndex];
const close = open === '{' ? '}' : ']'; const close = open === '{' ? '}' : ']';
@@ -208,7 +208,7 @@ function extractBalancedJSON(text, startIndex) {
* @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。 * @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。
* @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。 * @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。
*/ */
function extractJSONText(text) { export function extractJSONText(text) {
const stripped = stripOuterFence(text); const stripped = stripOuterFence(text);
try { try {
JSON.parse(stripped); JSON.parse(stripped);
+1 -1
View File
@@ -171,7 +171,7 @@ function toExclusion(botFinding) {
* @param {string} p - 待檢查的檔案路徑。 * @param {string} p - 待檢查的檔案路徑。
* @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。 * @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。
*/ */
function isSafeRepoPath(p) { export function isSafeRepoPath(p) {
if (typeof p !== 'string' || p === '') return false; if (typeof p !== 'string' || p === '') return false;
if (p.startsWith('/') || /^[a-zA-Z]:/.test(p)) return false; if (p.startsWith('/') || /^[a-zA-Z]:/.test(p)) return false;
return !p.split('/').includes('..'); return !p.split('/').includes('..');
+94
View File
@@ -420,3 +420,97 @@ describe('postFindingsReview', () => {
assert.deepEqual(inlineCalls.map(c => `${c.path}:${c.line}`), ['app/a.js:5', 'app/b.js:9']); assert.deepEqual(inlineCalls.map(c => `${c.path}:${c.line}`), ['app/a.js:5', 'app/b.js:9']);
}); });
}); });
describe('formatFindingsStats markdown formatting', () => {
// 代表性輸入:critical/warning/info 混合,含 is_new:false(舊問題)與未分類等級(custom)
const mixedFindings = [
{ level: 'critical', is_new: true },
{ level: 'critical', is_new: true },
{ level: 'warning', is_new: true },
{ level: 'info' }, // 未設 is_new 視為新問題
{ level: 'custom', is_new: true }, // 無法標示(不在 LEVEL_ORDER
{ level: 'critical', is_new: false }, // 舊問題
{ level: 'warning', is_new: false }, // 舊問題
{ level: 'mystery', is_new: false }, // 舊問題 + 無法標示
];
it('emits the exact header, separator and one row per type for mixed findings', () => {
const stats = formatFindingsStats(mixedFindings);
assert.equal(stats, [
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
'| --- | --- | --- | --- | --- |',
'| 新問題 | 2 筆 | 1 筆 | 1 筆 | 1 筆 |',
'| 舊問題 | 1 筆 | 1 筆 | 0 筆 | 1 筆 |',
].join('\n'));
});
it('produces a structurally valid markdown table (4 lines, 6 pipes each, 5 columns)', () => {
const lines = formatFindingsStats(mixedFindings).split('\n');
// 表頭 + 分隔列 + 新問題列 + 舊問題列
assert.equal(lines.length, 4);
// 每列皆以 pipe 起訖
for (const row of lines) {
assert.ok(row.startsWith('| '), `row should start with a pipe: ${row}`);
assert.ok(row.endsWith(' |'), `row should end with a pipe: ${row}`);
// 5 欄 => 6 個 pipe 分隔符
assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
}
// 分隔列每格皆為 ---
assert.equal(lines[1], '| --- | --- | --- | --- | --- |');
// 資料列以 類型 標籤起頭
assert.match(lines[2], /^\| 新問題 \|/);
assert.match(lines[3], /^\| 舊問題 \|/);
});
it('returns a stable, non-broken table for an empty findings array', () => {
let stats;
assert.doesNotThrow(() => { stats = formatFindingsStats([]); });
assert.equal(stats, [
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
'| --- | --- | --- | --- | --- |',
'| 新問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
'| 舊問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
].join('\n'));
// 即使無資料仍維持 4 列、每列 6 個 pipe 的結構
const lines = stats.split('\n');
assert.equal(lines.length, 4);
for (const row of lines) {
assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
}
});
});
describe('formatFindingsStatsLine markdown formatting', () => {
const mixedFindings = [
{ level: 'critical', is_new: true },
{ level: 'critical', is_new: true },
{ level: 'warning', is_new: true },
{ level: 'info' },
{ level: 'custom', is_new: true },
{ level: 'critical', is_new: false },
{ level: 'warning', is_new: false },
{ level: 'mystery', is_new: false },
];
it('produces the exact single-line summary for mixed findings', () => {
assert.equal(
formatFindingsStatsLine(mixedFindings),
'新: 嚴重2 / 警告1 / 建議1 / 無法標示1;舊: 嚴重1 / 警告1 / 建議0 / 無法標示1',
);
});
it('returns a stable single line with zero counts for an empty findings array', () => {
let lineSummary;
assert.doesNotThrow(() => { lineSummary = formatFindingsStatsLine([]); });
assert.equal(
lineSummary,
'新: 嚴重0 / 警告0 / 建議0 / 無法標示0;舊: 嚴重0 / 警告0 / 建議0 / 無法標示0',
);
// 單行:不含換行
assert.ok(!lineSummary.includes('\n'));
});
});
+45 -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, resolveMissingLineNumbers } from '../findings.js'; import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers, normalizeText } 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', () => {
@@ -349,3 +349,47 @@ describe('findings exclusions', () => {
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`))); 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));
}
});
});
+84
View File
@@ -139,3 +139,87 @@ describe('json helpers', () => {
); );
}); });
}); });
describe('repairJSONArrayWithAI', () => {
it('returns a clean JSON array string parseable by the caller', async () => {
const repaired = await repairJSONArrayWithAI(
'/tmp/x.json',
'.gitea/ai-review/findings.json',
'{not valid json',
async () => '[{"id":1},{"id":2}]'
);
assert.equal(repaired, '[{"id":1},{"id":2}]');
assert.deepEqual(JSON.parse(repaired), [{ id: 1 }, { id: 2 }]);
});
it('strips a fenced ```json block from the AI output', async () => {
const repaired = await repairJSONArrayWithAI(
'/tmp/x.json',
'.gitea/ai-review/exclusions.json',
'garbage',
async () => '```json\n[1, 2, 3]\n```'
);
assert.equal(repaired, '[1, 2, 3]');
assert.deepEqual(JSON.parse(repaired), [1, 2, 3]);
});
it('falls back to an empty array when the model cannot repair the content', async () => {
const repaired = await repairJSONArrayWithAI(
'/tmp/x.json',
'.gitea/ai-review/findings.json',
'totally unparseable !!! @@@',
async () => '[]'
);
assert.equal(repaired, '[]');
assert.deepEqual(JSON.parse(repaired), []);
});
it('returns garbage unchanged (no parsing/throwing) so the caller can validate', async () => {
const repaired = await repairJSONArrayWithAI(
'/tmp/x.json',
'.gitea/ai-review/findings.json',
'{broken',
async () => 'not a json array at all'
);
assert.equal(repaired, 'not a json array at all');
assert.throws(() => JSON.parse(repaired));
});
it('invokes chatFn once with the strict system prompt and JSON-encoded context', async () => {
const calls = [];
const repaired = await repairJSONArrayWithAI(
'/tmp/findings.json',
'.gitea/ai-review/findings.json',
'{broken',
async (systemPrompt, userContent) => {
calls.push({ systemPrompt, userContent });
return '[]';
}
);
assert.equal(repaired, '[]');
assert.equal(calls.length, 1);
assert.ok(calls[0].systemPrompt.includes('你是 JSON 修復器'));
assert.ok(calls[0].systemPrompt.includes('回傳 []'));
const context = JSON.parse(calls[0].userContent);
assert.deepEqual(context, {
file: '.gitea/ai-review/findings.json',
path: '/tmp/findings.json',
rawText: '{broken'
});
});
it('propagates errors thrown by chatFn', async () => {
await assert.rejects(
() => repairJSONArrayWithAI('/tmp/x.json', '.gitea/ai-review/findings.json', '{broken', async () => {
throw new Error('llm unavailable');
}),
/llm unavailable/
);
});
});
+88
View File
@@ -1,6 +1,7 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import axios from 'axios'; import axios from 'axios';
import { extractBalancedJSON, extractJSONText } from '../llm.js';
const ENV_KEYS = [ const ENV_KEYS = [
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
@@ -143,3 +144,90 @@ describe('chatJSON', async () => {
assert.deepEqual(result, []); assert.deepEqual(result, []);
}); });
}); });
describe('extractBalancedJSON', () => {
it('returns the whole object for a simple object from index 0', () => {
const text = '{"a":1}';
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
});
it('returns the full balanced segment for deeply nested object/array', () => {
const text = '{"a":[1,{"b":[2,{"c":3}]}],"d":4}';
assert.equal(extractBalancedJSON(text, 0), '{"a":[1,{"b":[2,{"c":3}]}],"d":4}');
});
it('does not let braces inside a string value break balancing', () => {
const text = '{"a":"}{"}';
assert.equal(extractBalancedJSON(text, 0), '{"a":"}{"}');
});
it('handles an escaped quote inside a string value', () => {
const text = '{"a":"\\""}';
assert.equal(extractBalancedJSON(text, 0), '{"a":"\\""}');
});
it('returns null for truncated/incomplete JSON', () => {
const text = '{"a":1';
assert.equal(extractBalancedJSON(text, 0), null);
});
it('extracts a balanced array when starting at a "["', () => {
const text = '[1,[2,3],{"a":4}]';
assert.equal(extractBalancedJSON(text, 0), '[1,[2,3],{"a":4}]');
});
it('excludes trailing content after the balanced segment', () => {
const text = '{"a":1} trailing text {"b":2}';
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
});
});
describe('extractJSONText', () => {
it('strips a fenced ```json block', () => {
const text = '```json\n{"a":1}\n```';
const result = extractJSONText(text);
assert.deepEqual(JSON.parse(result), { a: 1 });
});
it('extracts a JSON object after leading prose', () => {
const text = 'Here are the findings:\n{"level":"critical"}';
const result = extractJSONText(text);
assert.deepEqual(JSON.parse(result), { level: 'critical' });
});
it('extracts an array embedded in surrounding text', () => {
const text = 'prefix [1,2,3] suffix';
const result = extractJSONText(text);
assert.deepEqual(JSON.parse(result), [1, 2, 3]);
});
it('returns an already-pure JSON string as-is', () => {
const text = '{"a":1,"b":[2,3]}';
const result = extractJSONText(text);
assert.equal(result, '{"a":1,"b":[2,3]}');
assert.deepEqual(JSON.parse(result), { a: 1, b: [2, 3] });
});
it('returns the de-fenced original text when no valid JSON is found', () => {
const text = '```\nnot json at all\n```';
const result = extractJSONText(text);
assert.equal(result, 'not json at all');
});
});
+39
View File
@@ -8,6 +8,7 @@ import {
reconcileConversations, reconcileConversations,
dropResolvedFindings, dropResolvedFindings,
addCarriedFindings, addCarriedFindings,
isSafeRepoPath,
} from '../resolve.js'; } from '../resolve.js';
const reviewBody = (level, role, problem, suggestion) => const reviewBody = (level, role, problem, suggestion) =>
@@ -337,3 +338,41 @@ describe('addCarriedFindings', () => {
assert.equal(addCarriedFindings(findings, []), findings); assert.equal(addCarriedFindings(findings, []), findings);
}); });
}); });
describe('isSafeRepoPath', () => {
it('accepts a normal relative path', () => {
assert.equal(isSafeRepoPath('src/index.js'), true);
});
it('accepts a deep but safe relative path', () => {
assert.equal(isSafeRepoPath('a/b/c.js'), true);
});
it('rejects paths containing ../ traversal', () => {
assert.equal(isSafeRepoPath('../../etc/passwd'), false);
});
it('rejects a segment that is exactly .. (incl. middle of path)', () => {
assert.equal(isSafeRepoPath('a/../b'), false);
// 反斜線 ..\ 形式:以 / 切割後整段仍為 ..\... 非單純 ..,但起首相對路徑仍判定安全
assert.equal(isSafeRepoPath('a/..'), false);
});
it('rejects leading-slash absolute paths', () => {
assert.equal(isSafeRepoPath('/etc/passwd'), false);
});
it('rejects Windows drive-letter prefixes', () => {
assert.equal(isSafeRepoPath('C:\\Windows\\system32'), false);
});
it('rejects an empty string', () => {
assert.equal(isSafeRepoPath(''), false);
});
it('rejects non-string inputs', () => {
assert.equal(isSafeRepoPath(null), false);
assert.equal(isSafeRepoPath(undefined), false);
assert.equal(isSafeRepoPath(123), false);
});
});