diff --git a/src/checks/review-gates.ts b/src/checks/review-gates.ts index 26d7e57..5fddaaf 100644 --- a/src/checks/review-gates.ts +++ b/src/checks/review-gates.ts @@ -86,25 +86,45 @@ export function evalSpecReviewsRetention( } } -// R 审查文扫描(DEF-003 T4 真值源 · 2.3-W4 升级为最新轮返回):扫描 docs/harness/reviews 与 -// reviews/ 双路径,文件名口径 task__audit_R_*.md(slug 去 _v 版本后缀); -// 返回最高 R 轮文件(终轮结论为真值 · R2 通过覆盖 R1 退回);无命中 → null。 +// R 审查文扫描(DEF-003 T4 真值源 · 2.3-W4 升级为最新轮返回):扫描 docs/harness/reviews、 +// reviews/ 与 docs/audits/task-review/ 三址(址B = verify-upstream-dual-address · 下游仓 +// CONVENTION_review_gate_dual_address_v1_zh.md 双址规约的上游根治面)。 +// 命名口径两种(按址匹配 · 不串址): +// 址A(docs/harness/reviews · reviews/):task__audit_R_*.md(slug 含 task_ 前缀 · underscore 形) +// 址B(docs/audits/task-review/):YYYYMMDD__r.md(slug 无 task_ 前缀 · dash 形 · 小写 _r) +// slug 去 _v 版本后缀;址B 匹配过 normalizeSlug 归一(dash ↔ underscore)并去 task_ 前缀。 +// 返回最高 R 轮文件(终轮结论为真值 · R2 通过覆盖 R1 退回 · 跨址合并同一语义);无命中 → null。 export function findLatestReview( target: string, taskFile: string, ): { path: string; name: string; round: number } | null { - const dirs = [path.join(target, 'docs/harness/reviews'), path.join(target, 'reviews')] + // 命名口径按址绑定(不串址):址A 两目录只认 task_*_audit_R_* · 址B 只认 YYYYMMDD__r + const dirs: { dir: string; addrB: boolean }[] = [ + { dir: path.join(target, 'docs/harness/reviews'), addrB: false }, + { dir: path.join(target, 'reviews'), addrB: false }, + { dir: path.join(target, 'docs/audits/task-review'), addrB: true }, + ] const stripVer = (s: string) => s.replace(/_v\d+$/, '') const base = stripVer(path.basename(taskFile, '.md')) const RE = /^(task_.+?)_audit_R(\d+)_.*\.md$/i + const RE_ADDR_B = /^\d{8}_(.+?)_r(\d+)\.md$/i + const slugNormB = normalizeSlug(stripVer(base.replace(/^task_/i, ''))) let best: { path: string; name: string; round: number } | null = null - for (const reviewsDir of dirs) { + for (const { dir: reviewsDir, addrB } of dirs) { if (!existsSync(reviewsDir)) continue for (const name of readdirSync(reviewsDir)) { - const m = name.match(RE) - if (!m) continue - if (stripVer(m[1]!) !== base) continue // 捕获组必参与(E5 收窄) - const round = parseInt(m[2]!, 10) + let round: number | null = null + if (!addrB) { + const m = name.match(RE) + if (!m) continue + if (stripVer(m[1]!) !== base) continue // 捕获组必参与(E5 收窄) + round = parseInt(m[2]!, 10) + } else { + const mb = name.match(RE_ADDR_B) + if (!mb) continue + if (normalizeSlug(stripVer(mb[1]!)) !== slugNormB) continue // 捕获组必参与(E5 收窄同口径) + round = parseInt(mb[2]!, 10) + } if (!best || round > best.round || (round === best.round && name > best.name)) { best = { path: path.join(reviewsDir, name), name, round } } diff --git a/test/cli-verify-review.test.ts b/test/cli-verify-review.test.ts index ba9cd29..c845fd0 100644 --- a/test/cli-verify-review.test.ts +++ b/test/cli-verify-review.test.ts @@ -5,6 +5,7 @@ import os from 'node:os' import path from 'node:path' import { describe, it } from 'node:test' import { fileURLToPath } from 'node:url' +import { findLatestReview } from '../src/checks/review-gates.ts' const KIT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const CLI_TS = path.join(KIT, 'src', 'cli.ts') @@ -175,3 +176,54 @@ describe('DEF-003 T4 · verify 查 R 审查文存在性', { concurrency: 1 }, }) }) }) + +// verify-upstream-dual-address(ops-desk-api 仓 CONVENTION_review_gate_dual_address_v1_zh.md 上游根治): +// findLatestReview 扩第三扫描址 docs/audits/task-review/(址B)+ 第二命名口径 YYYYMMDD__r.md。 +// 址B slug 为 dash 形(hitl-auto-resume-claim-race)· task slug 为 underscore 形(task_hitl_auto_resume_claim_race) +// → 匹配须过 normalizeSlug 归一且去 task_ 前缀;跨址合并沿用「最高 R 轮取最新」语义。 +// 红→绿钉死:修复前址B 完全不入扫描面(址B 单落 verify 误报 missing R review BLOCKED · 误报两起在案)。 +describe('verify-upstream-dual-address · 址B docs/audits/task-review/ 扫描与命名口径', { concurrency: 1 }, () => { + const REVIEW_BODY = '# R1 fixture\n\n## 结论\n\nPASS · 零内容阻塞(fixture)\n\n审查结论:fixture 范围与验收全项合规,无阻塞遗留,准予关账。\n' + + it('址B 单落(不落址A)→ VERIFY: PASS · exit 0(命名 YYYYMMDD__r.md · dash slug)', async () => { + await withTemp(async (dir) => { + await seedTask(dir) + await writeRel(dir, 'docs/audits/task-review/20260820_vr-ok_r1.md', REVIEW_BODY) + const r = runCli(['verify', '--task', TASK_REL, '--target', dir]) + assert.equal(r.status, 0, r.combined) + assert.match(r.combined, /VERIFY: PASS/) + }) + }) + + it('址B 落址A 式命名(task_*_audit_R1_*)不认 → 仍 BLOCKED(址B 命名口径不串址)', async () => { + await withTemp(async (dir) => { + await seedTask(dir) + await writeRel(dir, 'docs/audits/task-review/task_vr_ok_audit_R1_2026-08-20.md', REVIEW_BODY) + const r = runCli(['verify', '--task', TASK_REL, '--target', dir]) + assert.equal(r.status, 2, r.combined) + assert.match(r.combined, /VERIFY: BLOCKED · missing R review/) + }) + }) + + it('findLatestReview 跨址合并取最高 R 轮:址A R1 + 址B r2 → round=2 命中址B 档', async () => { + await withTemp(async (dir) => { + await seedTask(dir) + await writeRel(dir, REVIEW_REL, REVIEW_BODY) + await writeRel(dir, 'docs/audits/task-review/20260821_vr-ok_r2.md', REVIEW_BODY) + const latest = findLatestReview(dir, path.join(dir, TASK_REL)) + assert.ok(latest, '址A+址B 双落须有命中') + assert.equal(latest.round, 2) + assert.match(latest.name, /^20260821_vr-ok_r2\.md$/) + }) + }) + + it('findLatestReview 址B slug 去 _v 版本后缀生效(task 文件 task_vr_suff_v3.md ↔ 址B vr-suff_r1)', async () => { + await withTemp(async (dir) => { + await seedTask(dir, 'docs/tasks/active/task_vr_suff_v3.md', 'vr_suff') + await writeRel(dir, 'docs/audits/task-review/20260820_vr-suff_r1.md', REVIEW_BODY) + const latest = findLatestReview(dir, path.join(dir, 'docs/tasks/active/task_vr_suff_v3.md')) + assert.ok(latest, '址B 档须命中 _v 后缀 task') + assert.equal(latest.round, 1) + }) + }) +})