docs: 補齊 JSDoc 與指令檔註解、測試移至 app/test 並重建 README #10

Merged
admin merged 16 commits from ai-review-resolve/develop-20260626-141504 into develop 2026-06-26 07:42:55 +00:00
3 changed files with 90 additions and 6 deletions
Showing only changes of commit 402630fa69 - Show all commits
+7 -4
View File
@@ -7,17 +7,20 @@ import { ok, line, warn } from './log.js';
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
const LEVEL_ORDER = ['critical', 'warning', 'info'];
// 預先把等級對應到排序索引,bySeverity 排序時直接 O(1) 取值,省去每次比較的 includes + indexOf 掃描。
const LEVEL_RANK = new Map(LEVEL_ORDER.map((level, index) => [level, index]));
/**
* 將單一 finding 格式化為 Markdown 表格的一列(等級|審查員|位置|建議)。
*
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Mage
問題findingRow 直接存取 f.role 且無空值檢查,f 為空值時會造成程式崩潰。
建議:加入空值判斷,避免程式崩潰

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:`findingRow` 直接存取 `f.role` 且無空值檢查,`f` 為空值時會造成程式崩潰。 **建議**:加入空值判斷,避免程式崩潰
Review

嚴重等級🔴 嚴重
審查員:Mage
問題findingRow 直接存取 f.role 且無空值檢查,f 為空值時會造成程式崩潰。
建議:加入空值判斷,避免程式崩潰

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:`findingRow` 直接存取 `f.role` 且無空值檢查,`f` 為空值時會造成程式崩潰。 **建議**:加入空值判斷,避免程式崩潰
* @param {{ level?: string, role?: string, location?: string, suggestion?: string }} f
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Maya
問題:新增了許多輔助性的 formatting 函式(如 findingRow, buildTable 等),雖為內部使用,但這類字串處理邏輯若沒有測試覆蓋,極易因修改格式而導致 Markdown 輸出損壞。
建議:雖然是輔助函式,但建議在 test/comments.test.js 中補齊這些 formatting 函式的斷言測試,確保輸出格式穩定。

**嚴重等級**:🔵 建議 **審查員**:Maya **問題**:新增了許多輔助性的 formatting 函式(如 findingRow, buildTable 等),雖為內部使用,但這類字串處理邏輯若沒有測試覆蓋,極易因修改格式而導致 Markdown 輸出損壞。 **建議**:雖然是輔助函式,但建議在 test/comments.test.js 中補齊這些 formatting 函式的斷言測試,確保輸出格式穩定。
* 單筆審查問題物件。`level` 若不在 critical/warning/info 之內,emoji 留空、標籤回退為原始 level 值;
* `role`、`location`、`suggestion` 直接內嵌字串(未定義時會輸出 undefined 字樣)。傳入 null/undefined 會拋 TypeError(需人工確認是否需防呆)。
* @returns {string} 形如 `| 🔴 嚴重 | role | location | suggestion |` 的表格列字串。
* `role`、`location`、`suggestion` 直接內嵌字串(未定義時會輸出 undefined 字樣)。傳入 null/undefined 時回傳空字串(已防呆,不會拋例外)。
* @returns {string} 形如 `| 🔴 嚴重 | role | location | suggestion |` 的表格列字串`f` 為空值時回傳空字串
* @remarks 內部輔助函式,供 {@link buildTable} 逐列組裝表格使用,本身不含換行。
*/
function findingRow(f) {
if (!f) return '';
return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`;
}
2
@@ -50,8 +53,8 @@ const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] ||
* @remarks 不在 LEVEL_ORDER 內的等級一律視為最低優先(排在最後);location 未定義時以空字串參與比較,因此排序穩定不會丟例外。
*/
const bySeverity = (a, b) => {
const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length;
const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length;
const aLevel = LEVEL_RANK.has(a.level) ? LEVEL_RANK.get(a.level) : LEVEL_ORDER.length;
const bLevel = LEVEL_RANK.has(b.level) ? LEVEL_RANK.get(b.level) : LEVEL_ORDER.length;
if (aLevel !== bLevel) return aLevel - bLevel;
return String(a.location || '').localeCompare(String(b.location || ''));
};
1
+4 -2
View File
3
@@ -107,8 +107,10 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
try {
const original = readJSONText(fullPath, label);
const repaired = await repairer(fullPath, label, original);
fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8');
JSON.parse(readJSONText(fullPath, label));
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
JSON.parse(normalized);
fs.writeFileSync(fullPath, normalized, 'utf8');
ok(`${label} 已由 AI 修正並通過再次驗證`);
return { exists: true, valid: true, repaired: true };
} catch (repairErr) {
+79
View File
1
@@ -140,6 +140,85 @@ describe('json helpers', () => {
});
});
describe('validateJSONArrayFile repair failure paths', () => {
let workspace;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'json-test-repair-'));
});
afterEach(() => {
fs.rmSync(workspace, { recursive: true, force: true });
});
it('overwrites the invalid file with the valid array returned by the repairer', async () => {
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, '{ this is not json', 'utf8');
let receivedOriginal;
const result = await validateJSONArrayFile(
fullPath,
'.gitea/ai-review/findings.json',
async (passedPath, passedLabel, original) => {
// repairer is called with (fullPath, label, original) per json.js line 109
assert.equal(passedPath, fullPath);
assert.equal(passedLabel, '.gitea/ai-review/findings.json');
receivedOriginal = original;
return '[{"id":1},{"id":2}]';
}
);
assert.equal(receivedOriginal, '{ this is not json');
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
// file is overwritten with the repaired content, trailing newline appended (line 110)
const written = fs.readFileSync(fullPath, 'utf8');
assert.equal(written, '[{"id":1},{"id":2}]\n');
assert.deepEqual(JSON.parse(written), [{ id: 1 }, { id: 2 }]);
});
it('throws when the repaired text is still invalid JSON and does NOT overwrite the original file', async () => {
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
const originalBytes = '{ still broken';
fs.writeFileSync(fullPath, originalBytes, 'utf8');
const stillInvalid = 'not a json array either';
await assert.rejects(
() => validateJSONArrayFile(
fullPath,
'.gitea/ai-review/findings.json',
async () => stillInvalid
),
SyntaxError
);
// json.js validates the repaired text in-memory BEFORE writing, so an invalid
// repair throws without corrupting the file — the original bytes are preserved.
const after = fs.readFileSync(fullPath, 'utf8');
assert.equal(after, originalBytes);
});
it('propagates an error thrown by the repairer', async () => {
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, '{ broken', 'utf8');
const boom = new Error('repairer exploded');
await assert.rejects(
() => validateJSONArrayFile(
fullPath,
'.gitea/ai-review/findings.json',
async () => { throw boom; }
),
/repairer exploded/
);
// repairer threw before any write happened, so the original bytes remain untouched (line 109)
assert.equal(fs.readFileSync(fullPath, 'utf8'), '{ broken');
});
});
describe('repairJSONArrayWithAI', () => {
it('returns a clean JSON array string parseable by the caller', async () => {
const repaired = await repairJSONArrayWithAI(