From 775cc575ae245ade1ce39ca515360b71a70a9b4c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:41:41 +0800 Subject: [PATCH] =?UTF-8?q?test(app):=20=E8=A3=9C=E9=BD=8A=20isSafeRepoPat?= =?UTF-8?q?h/extractBalancedJSON/normalizeText/repairJSONArrayWithAI/?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=87=BD=E5=BC=8F=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 為可測性 export 三個內部函式(isSafeRepoPath、extractBalancedJSON/extractJSONText、normalizeText)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/findings.js | 2 +- app/llm.js | 4 +- app/resolve.js | 2 +- app/test/comments.test.js | 94 +++++++++++++++++++++++++++++++++++++++ app/test/findings.test.js | 46 ++++++++++++++++++- app/test/json.test.js | 84 ++++++++++++++++++++++++++++++++++ app/test/llm.test.js | 88 ++++++++++++++++++++++++++++++++++++ app/test/resolve.test.js | 39 ++++++++++++++++ 8 files changed, 354 insertions(+), 5 deletions(-) diff --git a/app/findings.js b/app/findings.js index 9ede242..896aec4 100644 --- a/app/findings.js +++ b/app/findings.js @@ -108,7 +108,7 @@ function cleanText(value) { * @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。 * @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。 */ -function normalizeText(value) { +export function normalizeText(value) { return cleanText(value) .normalize('NFKC') .toLowerCase() diff --git a/app/llm.js b/app/llm.js index 51969fd..bd63588 100644 --- a/app/llm.js +++ b/app/llm.js @@ -165,7 +165,7 @@ function stripOuterFence(text) { * @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。 * @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。 */ -function extractBalancedJSON(text, startIndex) { +export function extractBalancedJSON(text, startIndex) { const source = String(text); const open = source[startIndex]; const close = open === '{' ? '}' : ']'; @@ -208,7 +208,7 @@ function extractBalancedJSON(text, startIndex) { * @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。 * @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。 */ -function extractJSONText(text) { +export function extractJSONText(text) { const stripped = stripOuterFence(text); try { JSON.parse(stripped); diff --git a/app/resolve.js b/app/resolve.js index 5308344..9303642 100644 --- a/app/resolve.js +++ b/app/resolve.js @@ -171,7 +171,7 @@ function toExclusion(botFinding) { * @param {string} p - 待檢查的檔案路徑。 * @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。 */ -function isSafeRepoPath(p) { +export function isSafeRepoPath(p) { if (typeof p !== 'string' || p === '') return false; if (p.startsWith('/') || /^[a-zA-Z]:/.test(p)) return false; return !p.split('/').includes('..'); diff --git a/app/test/comments.test.js b/app/test/comments.test.js index d39c32c..bd438f8 100644 --- a/app/test/comments.test.js +++ b/app/test/comments.test.js @@ -420,3 +420,97 @@ describe('postFindingsReview', () => { 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')); + }); +}); diff --git a/app/test/findings.test.js b/app/test/findings.test.js index 5dc1dc2..8ba4849 100644 --- a/app/test/findings.test.js +++ b/app/test/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, resolveMissingLineNumbers } from '../findings.js'; +import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers, normalizeText } from '../findings.js'; import { EXCLUSIONS_PATH, FINDINGS_PATH } from '../config.js'; describe('findings exclusions', () => { @@ -349,3 +349,47 @@ describe('findings exclusions', () => { 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)); + } + }); +}); diff --git a/app/test/json.test.js b/app/test/json.test.js index 636301a..6ea7f79 100644 --- a/app/test/json.test.js +++ b/app/test/json.test.js @@ -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/ + ); + }); +}); diff --git a/app/test/llm.test.js b/app/test/llm.test.js index 5d34206..b99dd94 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -1,6 +1,7 @@ import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import axios from 'axios'; +import { extractBalancedJSON, extractJSONText } from '../llm.js'; const ENV_KEYS = [ 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', @@ -143,3 +144,90 @@ describe('chatJSON', async () => { 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'); + }); +}); diff --git a/app/test/resolve.test.js b/app/test/resolve.test.js index 1969e0c..ed07dfa 100644 --- a/app/test/resolve.test.js +++ b/app/test/resolve.test.js @@ -8,6 +8,7 @@ import { reconcileConversations, dropResolvedFindings, addCarriedFindings, + isSafeRepoPath, } from '../resolve.js'; const reviewBody = (level, role, problem, suggestion) => @@ -337,3 +338,41 @@ describe('addCarriedFindings', () => { 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); + }); +});