Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 135 additions & 3 deletions packages/mcp-server/zcode-mcp-server
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,9 @@ def _run_zcode_headless(cmd, env, timeout):
max_output = max(10_000, _env_int(
"ZCODE_BRIDGE_MAX_OUTPUT", 10_000_000, maximum=100_000_000))
if len(text) > max_output:
log(f"⚠ zcode 输出超上限 ({len(text)} > {max_output} 字符), 截断")
log(f"⚠ zcode 输出超上限 ({len(text)} > {max_output} 字符), 截断"
" (报告尾部 VERDICT 行可能被切掉 → 下游无 zob-verdict 标记,"
" 走正则兜底)")
text = text[:max_output] + (
f"\n\n[... 输出超 {max_output} 字符已截断; "
"超大审查建议拆分文件分批进行 ...]")
Expand Down Expand Up @@ -1320,7 +1322,9 @@ def tool_zcode_pr_review(args):
"规则: 绝对不要修改、创建或删除任何文件 (写/执行工具已被物理禁用);"
"不要提出\"帮你修复\"的提议; 只输出审查报告。\n"
"输出格式: 先给汇总 (P0/P1/P2 各几条 + findings 确认/误报/存疑各几条"
" + 能否合并的结论), 再逐条详述。用中文。"
" + 能否合并的结论), 再逐条详述。用中文。\n"
"报告最后一行必须是严格的单行结论 (供机器解析, 字段不得增删改名):\n"
"VERDICT: P0=<非负整数> P1=<非负整数> P2=<非负整数> MERGE=<yes|no>"
)
if focus:
prompt += f"\n额外审查重点: {focus}。"
Expand All @@ -1330,7 +1334,12 @@ def tool_zcode_pr_review(args):

log(f"调用 zcode PR 复核 (yolo+只读黑名单, {len(changed)} 个改动文件)")
env = _merge_env_with_creds(load_zcode_credentials())
return _run_zcode_headless(cmd, env, _review_timeout())
result = _run_zcode_headless(cmd, env, _review_timeout())
# issue #16: 报告尾附机器可读 verdict 标记, 下游 (review-gate) 直读不猜
if not result.get("isError"):
result["content"][0]["text"] = _append_verdict_marker(
result["content"][0]["text"])
return result
finally:
if tmp_path:
try:
Expand All @@ -1339,6 +1348,129 @@ def tool_zcode_pr_review(args):
pass


_RE_FINAL_VERDICT = re.compile(
r"^\s*VERDICT:\s*P0\s*=\s*(\d{1,9})\s+P1\s*=\s*(\d{1,9})\s+"
r"P2\s*=\s*(\d{1,9})\s+MERGE\s*=\s*(yes|no)\s*$",
re.IGNORECASE,
)

# 文末「VERDICT 行 + 标记行」的完整追加形态 (幂等只认这个形状 — 是本函数
# 自己写出的; 正文里被复述/预埋的孤立标记不算, 狗食 review P1-1)。
# 捕获组供一致性校验 (二轮 P2-1: 数值不一致的尾置伪造按 VERDICT 行重写)。
# \d{1,9} 位数上限: 无界 \d+ 配 ≥4301 位数字会让 int() 抛 ValueError
# (Python ≥3.11 整数转换上限), 整次审查作废并烧满 gate 侧 5 次重审 (P2-4)。
_RE_ZOB_MARK_LINE = re.compile(
r"^<!-- zob-verdict:\{\"P0\":(\d{1,9}),\"P1\":(\d{1,9}),\"P2\":(\d{1,9}),"
r"\"merge\":(true|false)\} -->$")
# 正文中出现的完整注释形态标记 (被审代码可预埋) → 无条件消毒, 保证到达
# 下游的任何完整形态标记必然出自本文末追加 (狗食二轮 review P1-1: 兜底
# 路径同样不放行)。{0,200} 限界 (P2-3): 无界惰性量词最坏 O(n²), MB 级
# 构造文本可让单线程审查停摆分钟~小时级。
_RE_ZOB_MARK_ANY = re.compile(
r"<!--\s*zob-verdict:\{[^>]{0,200}\}\s*-->")

_SANITIZED_MARK = "[已消毒的 zob-verdict 引用]"
_SANITIZED_VERDICT = "[已消毒的 VERDICT 行引用]"


def _tail_verdict_marker(text):
"""拆出文末「VERDICT 行 + 标记行」紧邻形态: (verdict_m, marker_m, head)。

head 为 VERDICT 行之前的全部内容 (不含); 形状不符返回 None。只认绝对
最后一行是标记、其前最后一个非空行是结论行的形状 — 即本函数自己的
输出形状。
"""
lines = (text or "").rstrip().splitlines()
if not lines:
return None
marker_m = _RE_ZOB_MARK_LINE.match(lines[-1])
if not marker_m:
return None
prev_idx = None
for i in range(len(lines) - 2, -1, -1):
if lines[i].strip():
prev_idx = i
break
if prev_idx is None:
return None
verdict_m = _RE_FINAL_VERDICT.match(lines[prev_idx])
if not verdict_m:
return None
return verdict_m, marker_m, "\n".join(lines[:prev_idx])


def _verdict_marker_consistent(vm, zm):
"""尾置两行的标记数值与 VERDICT 行一致 (本函数自产形态的必然性质)。"""
return (int(vm.group(1)) == int(zm.group(1))
and int(vm.group(2)) == int(zm.group(2))
and int(vm.group(3)) == int(zm.group(3))
and ((vm.group(4).lower() == "yes") == (zm.group(4) == "true")))


def _append_verdict_marker(text):
r"""PR 复核报告尾附机器可读 verdict 标记 (issue #16)。

review-gate 之前靠正则猜报告正文的 P0/P1/P2 汇总计数, 报告格式稍变即
误读 (把表头/枚举句里的数字当计数) 或读不到 (fallback"解析失败")。
prompt 已要求报告以严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no"
收尾; 结论行命中则在文末追加 HTML 注释标记 (GitHub 评论里不渲染, 人读
无感):
<!-- zob-verdict:{"P0":0,"P1":2,"P2":3,"merge":false} -->

防伪造 (被审 PR 作者可预埋标记/结论行骗下游, 狗食两轮 review 攻防):
- **位置契约**: 只认全文最后一个非空行为结论行 (prompt 规定结论收尾)。
其余位置的 VERDICT 形态行一律按"引用"消毒改写 (P2-2: 防"真结论之后
的尾置引用行"劫持, 也防引用行喂下游 prose 正则)
- 消毒**无条件**执行: 找没找到结论行, 正文里的完整注释形态标记都先
改写掉 (P1-1: 兜底路径同样不放行)
- 幂等只认「VERDICT 行 + 标记行」且**数值一致**的自产形态 (P2-1:
形状对但数值不一致的尾置伪造 → 丢弃伪造标记, 以 VERDICT 行重写)
- 位数上限 \d{1,9} (P2-4): 超长数字串不构成合法结论行/标记, 不进
int() (Python ≥3.11 对 ≥4301 位整数串抛 ValueError)

结尾不是严格结论行 (格式漂移/尾置引用块) 时返回消毒后的正文, 不加
标记不编造 — 下游走旧正则兜底 + 人工核对降级。
"""
if not text:
return text
tail = _tail_verdict_marker(text)
if tail is not None:
verdict_m, marker_m, head = tail
if _verdict_marker_consistent(verdict_m, marker_m):
return text # 已是本函数追加过的可信形态
# P2-1: 两行形状对但数值不一致 → 预埋伪造, 丢弃尾标记以结论行重写
text = head.rstrip("\n") + "\n" + verdict_m.group(0).strip()
n_marks = len(_RE_ZOB_MARK_ANY.findall(text))
body = _RE_ZOB_MARK_ANY.sub(_SANITIZED_MARK, text) if n_marks else text
lines = body.splitlines()
last_idx = -1
for i, ln in enumerate(lines):
if ln.strip():
last_idx = i
if last_idx < 0:
return body
verdict_m = _RE_FINAL_VERDICT.match(lines[last_idx])
keep = last_idx if verdict_m is not None else None
if any(_RE_FINAL_VERDICT.match(ln)
for i, ln in enumerate(lines) if i != keep):
lines = [(_SANITIZED_VERDICT
if (i != keep and _RE_FINAL_VERDICT.match(ln)) else ln)
for i, ln in enumerate(lines)]
body = "\n".join(lines)
if verdict_m is None:
log(f"报告尾部无严格 VERDICT 结论行, 不附注标记 (消毒标记引用 "
f"{n_marks} 处, VERDICT 引用行已消毒)")
return body
p0, p1, p2 = int(verdict_m.group(1)), int(verdict_m.group(2)), \
int(verdict_m.group(3))
merge = "true" if verdict_m.group(4).lower() == "yes" else "false"
marker = (f'<!-- zob-verdict:{{"P0":{p0},"P1":{p1},"P2":{p2},'
f'"merge":{merge}}} -->')
log(f"verdict 标记已附注: P0={p0} P1={p1} P2={p2} merge={merge} "
f"(消毒正文标记引用 {n_marks} 处)")
return body.rstrip("\n") + "\n" + marker


def _env_int(name, default, maximum=None):
"""从环境变量读整数, 失败用默认值; maximum 给上界 (复审 R2 P2-5:
之前只靠调用点 max() 兜下限, env 误设天文数字没有防线)。"""
Expand Down
21 changes: 18 additions & 3 deletions packages/review-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,24 @@ zcode。
- **评论 at-least-once**:评论请求发出后响应丢失(超时/连接断开)会按失败
重试,而 GitHub issue comments 没有幂等键——极端情况下同一 head 可能
出现重复评论,属已知限制(方向仍是宁多勿漏)。
- **verdict 依赖报告文本解析**:从报告开头解析 `P0/P1/P2` 条数得出
pass/concerns;解析失败时 fail-safe 为 **concerns**(宁错拦不错放),
评论里会标注"严重度分布解析失败,请人工核对"。
- **verdict 依赖报告文本解析**:优先读报告尾的 `zob-verdict` 结构化标记
(mcp-server 要求 zcode 以严格单行 `VERDICT: P0=n P1=n P2=n MERGE=yes|no`
收尾并转写成 HTML 注释,issue #16);标记缺失时退回报告开头的正则解析。
两者都失败时 verdict 为 **需人工核对**(❓ unresolved)——不再误标
concerns:假红灯曾让下游把"可以合并"误读成"闸门卡死"。
**防伪造**(狗食 review P1-1):标记只认完整注释形态且取最后一个匹配
(真标记恒定在文末);mcp-server 转写前会把正文中被复述/预埋的完整注释
形态标记消毒掉——被审 PR 作者在代码里预埋标记无法骗过表头。结论行采用
**位置契约**(二轮 P2-2):只认全文最后一个非空行的严格 `VERDICT:` 行,
其余位置的 VERDICT 形态行一律按引用消毒(防"真结论之后的尾置引用行"
劫持,也防引用行喂 prose 正则);幂等只认「VERDICT 行+标记行」且数值
一致的自产形态(P2-1:不一致的尾置伪造按结论行重写);数字位数钳制
≤9 位(P2-4:≥4301 位整数串会让 Python ≥3.11 的 `int()` 抛错、烧满
重审)。标记明说 `merge=no` 时即使 P0/P1 全 0 也不给 pass(P2-1)。
**截断注意**:报告超 `ZCODE_BRIDGE_MAX_OUTPUT` 截断会切掉尾部 VERDICT 行
(标记缺失、退正则兜底,日志有提示);评论超 `max_body` 截断时贴出的
评论可能不含标记(verdict 在截断前已解析,表头仍正确;未来若有下游从
评论 HTML 反解标记需知此限制)。
- **单线程串行**:逐仓逐 PR 串行审查;并发安全靠 bridge mcp-server 侧的
跨进程文件锁兜底(多实例同时跑也不会并发打爆 zcode 限流)。
- **fork PR**:走 `refs/pull/{n}/head` 拉取,无需加 fork 远端;
Expand Down
84 changes: 68 additions & 16 deletions packages/review-gate/zcode-review-gate
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ zcode-review-gate — PR 自动审查闸门守护进程 (zcode-open-bridge 第 4
- token 不落盘: 只经 git≥2.31 的 GIT_CONFIG_COUNT/KEY/VALUE 环境变量
逐命令进程内注入 http.extraHeader (env 只对本用户可见, 优于 argv);
clone URL / .git/config / state / config 文件里都没有 token。
- verdict 解析是确定性 fail-safe: 严重度分布解析不出来一律按 concerns。
- verdict 解析: 优先读报告尾 zob-verdict 结构化标记 (mcp-server 侧从
严格单行 VERDICT 结论转写), 无标记退正文正则; 都失败标 unresolved
("需人工核对") 而非误标 concerns (issue #16: 假红灯曾致下游停工)。

依赖: 仅 Python3 标准库 (零第三方依赖)
日志: 全部走 stderr (带时间戳前缀)
Expand Down Expand Up @@ -56,15 +58,27 @@ PULLS_MAX_PAGES = 10 # 分页兜底: 单仓 open PR 超过 1000 个极罕
VERDICT_SCAN_HEAD = 3000 # verdict 只扫报告前 N 字符 (汇总段在开头, 防正文干扰)
MIN_COMMENT_BODY = 2000 # comment.max_body 下限 (小于模板开销的极端配置钳到这)

# 严重度分布解析: 在报告开头找 "P0 ... <数字>" 三段。确定性 fail-safe:
# 宁可解析失败按 concerns, 也不猜。P 必须大写 (不用 IGNORECASE),
# 严重度分布解析 (issue #16): 优先读报告尾的 zob-verdict 结构化标记 —
# 由 mcp-server 侧从严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no" 结论
# 转写, 解析端直读不猜。只认完整注释形态 (<!-- ... -->) 且取**最后一个**
# 匹配: 真标记由 mcp-server 恒定追加在文末, 正文里被复述/预埋的标记串
# (狗食 review P1-1: 被审 PR 可伪造) 排在真标记之前, 不采信。
# 无标记再退回正文正则 (下方三段)。
# 正文正则: 在报告开头找 "P0 ... <数字>" 三段。确定性 fail-safe: 宁可
# 解析失败标"需人工核对", 也不猜。P 必须大写 (不用 IGNORECASE),
# 防正文里 "p0" 之类的偶然命中。
# [^\dP] 排除 P 是关键: "P0/P1/P2 各 0/0/2" 枚举格式里 P0 后面紧跟
# "/P1", 若不排除 P 会把 P1 的数字算到 P0 头上 — 枚举格式统一解析失败
# 回 None (评论标注"解析失败请人工核对"), 也不拿错数字。
_RE_P0 = re.compile(r"P0[^\dP]{0,12}(\d+)(?!\d)")
_RE_P1 = re.compile(r"P1[^\dP]{0,12}(\d+)(?!\d)")
_RE_P2 = re.compile(r"P2[^\dP]{0,12}(\d+)(?!\d)")
# \d{1,9} 位数上限 (狗食二轮 P2-4): 无界 \d+ 配 ≥4301 位数字会让 int()
# 抛 ValueError (Python ≥3.11 整数转换上限), 整次审查作废并烧满重审;
# 超长数字串一律不构成合法标记/计数 (finding 数到十亿不现实)。
_RE_ZOB_VERDICT = re.compile(
r"<!-- zob-verdict:\{\"P0\":(\d{1,9}),\"P1\":(\d{1,9}),\"P2\":(\d{1,9}),"
r"\"merge\":(true|false)\} -->")
_RE_P0 = re.compile(r"P0[^\dP]{0,12}(\d{1,9})(?!\d)")
_RE_P1 = re.compile(r"P1[^\dP]{0,12}(\d{1,9})(?!\d)")
_RE_P2 = re.compile(r"P2[^\dP]{0,12}(\d{1,9})(?!\d)")

_TRUNC_MARK = "\n\n[... 报告超长已截断 ...]"

Expand Down Expand Up @@ -497,8 +511,31 @@ def run_review(cfg, clone, base_ref, head_sha):
# ============================================================
# verdict 解析 (确定性, fail-safe)
# ============================================================
def parse_verdict_marker(report):
"""读 zob-verdict 标记, 返回 ((P0, P1, P2), merge) 或 None。

只认完整注释形态且取最后一个匹配 — 真标记由 mcp-server 恒定追加在
文末 (且 mcp-server 侧已对正文标记引用消毒), 正文靠前的伪造标记
不采信 (狗食 review P1-1)。
"""
matches = list(_RE_ZOB_VERDICT.finditer(report or ""))
if not matches:
return None
m = matches[-1]
return ((int(m.group(1)), int(m.group(2)), int(m.group(3))),
m.group(4) == "true")


def parse_severity_counts(report):
"""从报告前 VERDICT_SCAN_HEAD 字符解析 (P0, P1, P2) 条数; 任一缺失 → None。"""
"""解析 (P0, P1, P2) 条数; 任一缺失 → None。

优先读报告尾的 zob-verdict 结构化标记 (issue #16: mcp-server 侧从
严格单行 VERDICT 结论转写, 取最后一个完整注释形态匹配); 无标记退回
正文前 VERDICT_SCAN_HEAD 字符的正则猜测 (自由 prose, 格式敏感, 仅兜底)。
"""
marker = parse_verdict_marker(report)
if marker:
return marker[0]
head = (report or "")[:VERDICT_SCAN_HEAD]
counts = []
for pattern in (_RE_P0, _RE_P1, _RE_P2):
Expand All @@ -509,11 +546,19 @@ def parse_severity_counts(report):
return tuple(counts)


def verdict_from_counts(counts):
"""counts=None (解析失败) → concerns (fail-safe); P0/P1>0 → concerns; 否则 pass。"""
def verdict_from_counts(counts, merge_from_marker=None):
"""counts=None (解析失败) → unresolved (issue #16: 表头标"需人工核对",
不再误标 concerns — 假红灯曾致下游指挥 agent 停工); P0/P1>0 →
concerns; 否则 pass。结构化标记明说 merge=no 时即使全 0 计数也不给
pass (狗食 review P2-1: 表头"可以合并"与报告结论直接矛盾是 issue #16
抱怨的误导残余形态)。"""
if counts is None:
return "unresolved"
if counts[0] > 0 or counts[1] > 0:
return "concerns"
if merge_from_marker is False:
return "concerns"
return "concerns" if (counts[0] > 0 or counts[1] > 0) else "pass"
return "pass"


def build_comment_body(verdict, counts, head_sha, report, max_body):
Expand All @@ -526,13 +571,16 @@ def build_comment_body(verdict, counts, head_sha, report, max_body):
max_body = max(_as_int(max_body, 60000), MIN_COMMENT_BODY)

def assemble(body_text):
icon = "✅ pass" if verdict == "pass" else "⚠️ concerns"
icon = {"pass": "✅ pass", "concerns": "⚠️ concerns"}.get(
verdict, "❓ 需人工核对")
if counts is not None:
sev = f"P0 × {counts[0]} · P1 × {counts[1]} · P2 × {counts[2]}"
else:
sev = "解析失败(严重度分布解析失败,请人工核对)"
conclusion = ("未发现阻断问题,可以合并" if verdict == "pass"
else "存在需关注的问题,合并前请处理")
sev = "解析失败(结构化标记与正文汇总均未解析出,请人工核对)"
conclusion = {
"pass": "未发现阻断问题,可以合并",
"concerns": "存在需关注的问题,合并前请处理",
}.get(verdict, "严重度分布未解析出,请人工核对报告正文")
return (
f"## ZCode Review Gate:{icon}\n\n"
"| 项 | 值 |\n"
Expand Down Expand Up @@ -733,10 +781,14 @@ def process_pr(cfg, state, token, owner, repo, clone, info):
return

report = payload
marker = parse_verdict_marker(report)
counts = parse_severity_counts(report)
verdict = verdict_from_counts(counts)
verdict = verdict_from_counts(counts, marker[1] if marker else None)
if counts is None:
log(f"{key}: verdict=concerns (严重度分布解析失败, fail-safe 请人工核对)")
log(f"{key}: verdict=unresolved (解析失败, 请人工核对)")
elif marker is not None and not marker[1] and verdict == "concerns" \
and counts[0] == 0 and counts[1] == 0:
log(f"{key}: verdict=concerns (标记 merge=no, 全 0 计数不给 pass)")
else:
log(f"{key}: verdict={verdict} "
f"(P0={counts[0]} P1={counts[1]} P2={counts[2]})")
Expand Down
Loading
Loading