From 32620c4517c39c8864656044124a02f1963a2116 Mon Sep 17 00:00:00 2001 From: host Date: Mon, 2 Nov 2026 03:31:34 +0000 Subject: [PATCH] code-to10 v6 A1: delete server merge-gate modes, hard only Server POST /tasks/{id}/merge no longer has record/enforce/off: hard_mode_from_env and HARD_MODES deleted (not re-defaulted), handler always evaluates the hard gate, env CLOUD_MERGE_GATE_MODE and body merge_gate_mode are ignored server-side. The record 'no-evidence passes' and 'unprotected+confirm passes with merge_gate_risk' server paths are gone with the dead risk audit; no production caller justified keeping them (only production caller of the non-hard evaluator was the merge handler itself; audit_loop_wiring already used the hard gate). cloud/merge_gate pure functions keep their #209 semantics but are documented as non-server: they are no longer wired to any endpoint. Server-level record/enforce/off tests rewritten to the new behavior (renamed, not deleted silently); pure-function tests unchanged. (cherry picked from commit 08e75edbd6d04701965c5db951fa054cfbb75bb0) Reconcile-port: handler_lifecycle.py hunks redirected into cloud/server.py _merge_task (target keeps 6-mixin split; mixin file does not exist here); handler_support.py kept at HEAD (its A1 hunk is a docstring of _validate_hard_gate_body, ported to server.py copy); test mock paths cloud.handler_lifecycle.* rewritten to cloud.server.*. --- cloud/merge_gate.py | 29 +++++-- cloud/merge_gate_hard.py | 53 ++++-------- cloud/server.py | 83 ++++++++----------- cloud/tests/test_audit_wiring.py | 7 +- cloud/tests/test_merge_gate_hard.py | 89 +++++++++++---------- cloud/tests/test_merge_gate_protectskill.py | 66 ++++++++------- cloud/tests/test_server_merge.py | 13 +-- cloud/tests/test_v2cov_server_handlers2.py | 43 +++++++--- 8 files changed, 200 insertions(+), 183 deletions(-) diff --git a/cloud/merge_gate.py b/cloud/merge_gate.py index 6239a83..bee912c 100644 --- a/cloud/merge_gate.py +++ b/cloud/merge_gate.py @@ -1,5 +1,15 @@ """合并门禁:主分支保护 + 目录隔离(纯函数,无网络、无 GitHub 调用)。 +A1 声明(服务端唯一门禁是 hard,不要拿本模块的模式推断服务端): +- 自 A1 起,服务端 POST /tasks/{id}/merge 一律按 + cloud/merge_gate_hard 的 hard 求值;环境变量 + CLOUD_MERGE_GATE_MODE 与请求体 merge_gate_mode 在服务端一律被 + 忽略,旧值 record/enforce/off 不再是服务端可选项。 +- 本模块以下写到的 record/enforce/off 只是 #209 纯函数的历史语义: + 它们只描述 evaluate_merge_gate / gate_from_request 这两个纯函数 + 本身,不再接线到任何服务端端点;纯函数单测保留这些语义,不代表 + 服务端会按此放行。需要服务端行为时只看 merge_gate_hard。 + 背景与诚实边界(先读,再用): - 本模块**不能**替仓库开通主分支保护。保护是 GitHub 仓库设置, 只能由仓库管理员在 GitHub 侧开通;GitHub App id 5476837 的权限 @@ -23,8 +33,9 @@ 不做 git 合并、不做目录检查、不做保护检查——那是本模块要补的 缺口,不是重复造检查。 -模式(CLOUD_MERGE_GATE_MODE,默认 record): -- record(默认):未保护时必须显式确认(explicit_confirm)才放行; +模式(以下仅为本模块纯函数的历史语义,不是服务端行为;服务端 +唯一门禁是 hard,见模块头 A1 声明): +- record(纯函数默认):未保护时必须显式确认(explicit_confirm)才放行; 无确认时阻断,错误码 branch_protection_risk_unconfirmed。 确认只覆盖“保护风险”,绝不覆盖目录隔离违规——越目录的写 只能靠 approved_exceptions(契约变更批准)放行,不能靠确认蒙混。 @@ -46,7 +57,9 @@ RISK_GATE_OFF = "merge gate is disabled (mode=off)" def gate_mode_from_env(env=None): - """环境变量里的模式;未设或非法时回落 record,绝不回落 off。""" + """纯函数模式解析(非服务端行为,服务端不读它)。 + + 环境变量里的模式;未设或非法时回落 record,绝不回落 off。""" raw = (env if env is not None else os.environ).get( "CLOUD_MERGE_GATE_MODE", "") mode = (raw or "").strip().lower() @@ -114,7 +127,7 @@ def check_directory_isolation(files_changed, owned_dir, def evaluate_merge_gate(files_changed=None, owned_dir=None, approved_exceptions=(), protection="unknown", mode="record", explicit_confirm=False): - """合并门禁纯函数。 + """合并门禁纯函数(非服务端门禁;服务端唯一门禁是 hard)。 files_changed=None 表示调用方没给 diff 证据:目录检查跳过 (directory=None),不因“没证据”就判违规,也不伪装成已检查。 @@ -151,14 +164,16 @@ def evaluate_merge_gate(files_changed=None, owned_dir=None, def gate_from_request(body, env=None): - """从 /merge 请求体 + 环境变量拼出门禁输入并求值。 + """从请求体 + 环境变量拼出门禁输入并求值(纯函数,非服务端路径。 + 服务端 POST /tasks/{id}/merge 不走本函数,一律按 hard 求值。 请求体字段(全是可选的;一个都不给时行为与旧接口一致, 只在响应里附带门禁结果,不阻断): files_changed(str 列表)、owned_dir、approved_exceptions、 branch_protection、merge_gate_confirm(bool)。 - 模式优先取环境变量(运维定),环境变量未设时才取请求体 - merge_gate_mode——请求体不能把运维定的 enforce 降级为 record。 + 模式(纯函数内)优先取环境变量,环境变量未设时才取请求体 + merge_gate_mode——纯函数内请求体不能把环境变量定的 enforce + 降级为 record;服务端则根本不读模式,一律 hard。 """ body = body or {} env_map = env if env is not None else os.environ diff --git a/cloud/merge_gate_hard.py b/cloud/merge_gate_hard.py index 2bec9ff..c9af555 100644 --- a/cloud/merge_gate_hard.py +++ b/cloud/merge_gate_hard.py @@ -1,24 +1,20 @@ """服务端合并硬门禁:五项全查、缺一 409、fail-closed(纯函数)。 -本模块堆叠在 #209(cloud/merge_gate.py)之上,不改 #209 的语义: -- #209 的 ``evaluate_merge_gate`` / ``gate_from_request`` 原样保留: - record 默认、无证据不阻断、unknown 只记风险不阻断。那些是 - #209 的过渡语义,本模块不碰它们,所以 #209 的测试一字不用改。 -- 本模块只新增一个模式值 ``hard``,以及服务端用它时的模式解析: - ``hard_mode_from_env``。服务端(POST /tasks/{id}/merge)以本模块 - 的解析为准,#209 的 ``gate_from_request`` 不再是服务端的模式来源 - (它仍可作纯函数使用,但服务端不再经由它决定模式——否则调用方 - 可用请求体 ``merge_gate_mode=record`` 把硬门禁降级,那是 - fail 但照样合并的路径,本模块堵的就是它)。 +本模块堆叠在 #209(cloud/merge_gate.py)之上,不改 #209 纯函数 +本身的语义,但服务端不再经由它决定模式: +- #209 的 ``evaluate_merge_gate`` / ``gate_from_request`` 只是 + 纯函数(record 默认、无证据不阻断、unknown 只记风险不阻断), + 自 A1 起不再接线到任何服务端端点;它们的 record/enforce/off + 语义是纯函数的历史语义,不是服务端行为,不得据此推断服务端 + 会放行(见 cloud/merge_gate.py 头部的 A1 声明)。 +- 服务端唯一门禁就是本模块的 hard:POST /tasks/{id}/merge + 一律按 hard 求值;环境变量 CLOUD_MERGE_GATE_MODE 与请求体 + ``merge_gate_mode`` 在服务端一律被忽略——旧值 record/enforce/off + 不再是可选项,设了也等同 hard,不存在“显式降级”的服务端路径。 + (此前 hard_mode_from_env 曾把显式 record/enforce/off 原样沿用, + A1 已删除该解析与 HARD_MODES:删模式,不是改默认值。) -模式(环境变量 CLOUD_MERGE_GATE_MODE,运维定、调用方改不了): -- hard(未设或非法值时都落到 hard,fail-closed,绝不回落 record/off): - 下面五项必须全部通过,缺一就阻断。调用方请求体里的 - ``merge_gate_mode`` 在服务端被忽略——模式只由环境变量决定。 -- record / enforce / off(必须显式设置):原样沿用 #209 的语义, - 属过渡/显式降级。record 下无证据调用仍按旧接口放行(只附带门禁 - 信息),enforce 下未保护一律阻断,off 下只记风险不阻断。切到 - 这三者是运维的显式决定,不是调用方的默认权利。 +模式(服务端唯一:hard): 硬门禁五项(全部是调用方在 /merge 请求体里提交的证据; 本服务没有可信渠道直连 GitHub 核实,所以门禁强制的是“证据齐全 @@ -50,7 +46,8 @@ PR/Checks 的只读核对去覆盖): 分支保护(硬模式下的第六条隐含项,来自 #209 的保护定义): ``branch_protection`` 必须为 ``"protected"``。``"unprotected"`` 即使带 ``merge_gate_confirm`` 也阻断——硬模式没有“确认即放行” - 的逃生口,确认只在 record 模式下覆盖保护风险。``"unknown"`` + 的逃生口(旧 record 模式下确认可覆盖保护风险的语义已随 A1 + 删模式一并失效,服务端不再有确认放行的路径)。``"unknown"`` (未声明或非法值)同样阻断:unknown 不等于 protected,这是 #209 里“unknown 只记风险”的过渡语义在硬模式下的收紧。 @@ -77,8 +74,6 @@ from cloud.merge_gate import ( check_directory_isolation, ) -HARD_MODES = ("hard", "record", "enforce", "off") - _BLOCK_DESCRIPTIONS = { "ci_evidence_missing": "CI 证据缺失(ci_status / ci_checks / required_checks 有一项未给)", "ci_not_green": "CI 状态不是 success", @@ -100,22 +95,6 @@ _BLOCK_DESCRIPTIONS = { } -def hard_mode_from_env(env=None): - """服务端有效的门禁模式:环境变量定,调用方改不了。 - - 未设、空值或非法值一律回落 hard(fail-closed)。注意这与 - #209 的 gate_mode_from_env 不同:后者非法值回落 record,那是 - #209 纯函数的过渡语义;服务端模式解析必须 fail-closed,所以 - 这里绝不回落到 record/off。 - """ - import os - - raw = (env if env is not None else os.environ).get( - "CLOUD_MERGE_GATE_MODE", "") - mode = (raw or "").strip().lower() - return mode if mode in HARD_MODES else "hard" - - def _ci_checks_index(ci_checks): """ci_checks 列表 -> {name: status};条目不合规时返回 None。""" index = {} diff --git a/cloud/server.py b/cloud/server.py index ed4449c..acb4811 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -82,7 +82,6 @@ from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults from cloud.evidence_binding import EvidenceNonceStore as _EvidenceNonceStore from cloud.evidence_binding import binding_presence as _evidence_presence from cloud.evidence_binding import validate_bound_evidence as _validate_evidence -from cloud.merge_gate import evaluate_merge_gate as _merge_gate_evaluate from cloud.merge_gate import protection_from_env as _merge_gate_protection from cloud.audit_wiring import pushback_block_for_task as _pushback_block from cloud.audit_loop_wiring import build_quota_check as _loop_quota_check @@ -97,7 +96,6 @@ from cloud.audit_loop_wiring import load_loop_state as _loop_load_state from cloud.ledger import LedgerStore as _LedgerStore from cloud.merge_gate_hard import blocked_audit_detail as _gate_blocked_detail from cloud.merge_gate_hard import evaluate_hard_merge_gate as _merge_gate_hard -from cloud.merge_gate_hard import hard_mode_from_env as _merge_gate_hard_mode from cloud.tree import DEFAULT_MAX_DEPTH as _TREE_DEFAULT_DEPTH from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES from cloud.tree import build_tree as _build_tree @@ -1780,11 +1778,10 @@ def sweep_stale_tasks(db_path, threshold_seconds=_STALE_SWEEP): def _validate_hard_gate_body(body): - """硬模式请求体类型校验:畸形字段回 400,不进门禁判定。 + """硬门禁请求体类型校验:畸形字段回 400,不进门禁判定。 - 只在 hard 模式调用;record/enforce/off 下这些字段按 #209 语义 - 被忽略,不因此 400。缺失字段不是畸形,是无证据,交门禁判 409。 - 返回错误串,无错返回 None。 + 服务端只有 hard 模式,该校验一律适用。缺失字段不是畸形, + 是无证据,交门禁判 409。返回错误串,无错返回 None。 """ for field in ("ci_checks", "required_checks", "approved_exceptions"): value = body.get(field) @@ -6395,12 +6392,16 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au 必须是 completed(其它状态一律 409,含已 merged 的重复 调用,避免二次合并混淆)。成功后写审计 merge_task。 - 合并门禁(cloud/merge_gate + cloud/merge_gate_hard,补 v0.3 + 合并门禁(cloud/merge_gate_hard,唯一服务端门禁,补 v0.3 技能文本检查在服务端缺失的缺口;v0.3 之前本接口只改状态, 不查目录、不查保护): - 模式只由环境变量 CLOUD_MERGE_GATE_MODE 决定,请求体里的 - merge_gate_mode 在服务端被忽略(调用方不能自行降级): - - hard(默认:环境变量未设或非法值都落到 hard):五项全查, + 服务端只有 hard 一种模式:环境变量 CLOUD_MERGE_GATE_MODE + 与请求体里的 merge_gate_mode 在服务端一律被忽略(旧值 + record/enforce/off 自 A1 起不再是服务端可选项,设了也等同 + hard;此前 record 下“无证据放行”、record 下“未保护 + 确认 + 放行并记 merge_gate_risk”、off 下“只记风险不阻断”三条 + 服务端路径已随删模式一并消失,不存在受控降级口)。 + hard:五项全查, 缺一 409 且不改状态,并写 merge_gate_blocked 审计。五项: ① CI 全绿(ci_status=success + ci_checks 全 success + required_checks 非空且全 success);② 目录隔离(v0.3 口径, @@ -6412,12 +6413,7 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au 另:branch_protection 必须为 protected;unprotected 即使带 merge_gate_confirm 也阻断,unknown 同样阻断(unknown 不等于 protected)。无证据不放行(fail-closed)。 - - record / enforce / off(必须显式设置,属过渡/显式降级): - 原样沿用 #209 语义:record 下无证据调用仍按旧接口放行 - (只在响应里附带门禁结果),未保护 + 无确认阻断、+ 确认 - 放行并记 merge_gate_risk;enforce 下未保护一律阻断; - off 下只记风险不阻断。确认只覆盖保护风险,绝不覆盖目录违规。 - 门禁阻断(任一模式)都写 merge_gate_blocked 审计后才回 409, + 门禁阻断都写 merge_gate_blocked 审计后才回 409, 这是第 3 轨自动推回的阻塞依据:推回包取响应 merge_gate 的 blocks 码,不要解析审计文本;本接口不建推回任务、不改状态机。 审计推回接线(cloud/audit_wiring,第 1 项接线缺口):五项 @@ -6426,8 +6422,8 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au merge_gate.blocks 里追加 audit_pushback_blocked 码、响应 里附 audit_pushback 对象(merge_blocked / release_blocked / blocked_by="audit_pushback" / round / failed_checks)。 - 推回阻塞与门禁模式无关:record 模式的显式确认只覆盖 - 保护风险,绝不覆盖推回阻塞;链读不到(audit_ledger 表 + 推回阻塞与门禁无关:显式确认在 hard 下不覆盖任何一项, + 绝不覆盖推回阻塞;链读不到(audit_ledger 表 不存在)视为无推回状态,其它读链失败一律 fail-closed 409,不放行。注意:本接口没有独立的发布端点, release_blocked 随响应返回、由未来的发布端点用同一 @@ -6469,36 +6465,27 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au body.get("files_changed"), list): self._send_json(400, {"error": "files_changed must be a list"}) return - mode = _merge_gate_hard_mode() protection = body.get("branch_protection") \ or _merge_gate_protection() - if mode == "hard": - hard_err = _validate_hard_gate_body(body) - if hard_err: - self._send_json(400, {"error": hard_err}) - return - gate = _merge_gate_hard( - files_changed=body.get("files_changed"), - owned_dir=body.get("owned_dir"), - approved_exceptions=body.get("approved_exceptions") or (), - protection=protection, - ci_status=body.get("ci_status"), - ci_checks=body.get("ci_checks"), - required_checks=body.get("required_checks"), - contract_version=body.get("contract_version"), - contract_frozen=body.get("contract_frozen", False), - contract_expected_version=body.get( - "contract_expected_version"), - coordinator_approved=body.get("coordinator_approved"), - unresolved_review_comments=body.get( - "unresolved_review_comments")) - else: - gate = _merge_gate_evaluate( - files_changed=body.get("files_changed"), - owned_dir=body.get("owned_dir"), - approved_exceptions=body.get("approved_exceptions") or (), - protection=protection, mode=mode, - explicit_confirm=bool(body.get("merge_gate_confirm"))) + hard_err = _validate_hard_gate_body(body) + if hard_err: + self._send_json(400, {"error": hard_err}) + return + gate = _merge_gate_hard( + files_changed=body.get("files_changed"), + owned_dir=body.get("owned_dir"), + approved_exceptions=body.get("approved_exceptions") or (), + protection=protection, + ci_status=body.get("ci_status"), + ci_checks=body.get("ci_checks"), + required_checks=body.get("required_checks"), + contract_version=body.get("contract_version"), + contract_frozen=body.get("contract_frozen", False), + contract_expected_version=body.get( + "contract_expected_version"), + coordinator_approved=body.get("coordinator_approved"), + unresolved_review_comments=body.get( + "unresolved_review_comments")) # 审计推回接线:链上阻塞与门禁五项为“与”关系,且与模式 # 无关。读链失败(表不存在除外,见 audit_wiring)时 # fail-closed:不证明无阻塞就不放行。 @@ -6567,10 +6554,6 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "merge_task", task_id) - if gate["protection"] == "unprotected": - store.audit(user_id, task["project_id"], - "merge_gate_risk", - "%s: %s" % (task_id, "; ".join(gate["risks"]))) task["merge_gate"] = gate self._send_json(200, task) diff --git a/cloud/tests/test_audit_wiring.py b/cloud/tests/test_audit_wiring.py index 2eefd92..747f516 100644 --- a/cloud/tests/test_audit_wiring.py +++ b/cloud/tests/test_audit_wiring.py @@ -601,10 +601,13 @@ class EndpointChainTestCase(unittest.TestCase): self._hard_body(), self.owner_key) self.assertEqual(status, 200, merged) - def test_pushback_blocks_even_in_record_mode(self): + def test_pushback_blocks_with_full_hard_evidence(self): + # A1 改写:旧用例 test_pushback_blocks_even_in_record_mode + # 已改名。本用例的性质不变——链上推回阻塞时,即使五项 + # 证据全齐也 409;只是不再经 record 模式证明“与模式无关”, + # 因为服务端已无其它模式可比。 task = self._completed_task() self._block_task(task) - os.environ["CLOUD_MERGE_GATE_MODE"] = "record" status, blocked = self._req( "POST", "/tasks/%s/merge" % task["id"], self._hard_body(), self.owner_key) diff --git a/cloud/tests/test_merge_gate_hard.py b/cloud/tests/test_merge_gate_hard.py index 88795bd..bf26c73 100644 --- a/cloud/tests/test_merge_gate_hard.py +++ b/cloud/tests/test_merge_gate_hard.py @@ -1,13 +1,13 @@ -"""服务端合并硬门禁(cloud/merge_gate_hard,默认 hard 模式)。 +"""服务端合并硬门禁(cloud/merge_gate_hard,唯一服务端门禁)。 -与 #209 的分工(不要把两套语义混成一套断言): -- #209 的纯函数与 record/enforce/off 语义原样保留,见 - test_merge_gate_protectskill.py;本文件不重测 record 的 - “无证据放行”,只测:record 仍是显式 env 才能选到的过渡模式。 -- 本文件测硬模式:默认(env 未设或非法值)即 hard,五项全查、 - 缺一 409、状态不变、merge_gate_blocked 审计;unknown 保护 - 不等于 protected;显式确认在硬模式下不覆盖任何一项; - 调用方请求体里的 merge_gate_mode 不能把硬门禁降级。 +A1 之后服务端不再有 record/enforce/off 模式:环境变量 +CLOUD_MERGE_GATE_MODE 与请求体 merge_gate_mode 在服务端一律被 +忽略,旧值设了也等同 hard。本文件测硬模式:无证据即 409、 +五项全查、缺一 409、状态不变、merge_gate_blocked 审计;unknown +保护不等于 protected;显式确认在硬模式下不覆盖任何一项;旧模式 +环境变量不再产生旧行为(下面“旧模式值一律按 hard”用例钉住)。 +#209 纯函数的 record 语义只见 test_merge_gate_protectskill.py 的 +纯函数用例,不代表服务端行为。 """ import http.client @@ -23,13 +23,13 @@ from pathlib import Path try: from cloud.auth import AuthStore from cloud.merge_gate_hard import ( - blocked_audit_detail, evaluate_hard_merge_gate, hard_mode_from_env) + blocked_audit_detail, evaluate_hard_merge_gate) from cloud.server import create_server except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.auth import AuthStore from cloud.merge_gate_hard import ( - blocked_audit_detail, evaluate_hard_merge_gate, hard_mode_from_env) + blocked_audit_detail, evaluate_hard_merge_gate) from cloud.server import create_server @@ -50,17 +50,16 @@ def _hard_kwargs(**overrides): return kwargs -class HardModeEnvTestCase(unittest.TestCase): - def test_unset_and_invalid_mode_fall_to_hard(self): - self.assertEqual(hard_mode_from_env({}), "hard") - self.assertEqual(hard_mode_from_env( - {"CLOUD_MERGE_GATE_MODE": ""}), "hard") - self.assertEqual(hard_mode_from_env( - {"CLOUD_MERGE_GATE_MODE": "bogus"}), "hard") - self.assertEqual(hard_mode_from_env( - {"CLOUD_MERGE_GATE_MODE": "record"}), "record") - self.assertEqual(hard_mode_from_env( - {"CLOUD_MERGE_GATE_MODE": "HARD"}), "hard") +class ServerModesRemovedTestCase(unittest.TestCase): + def test_no_mode_parser_or_mode_table_left(self): + # A1 删模式的锁定:服务端模式解析函数与模式表已删除, + # 不是改默认值——旧值无处可沿用。 + import cloud.merge_gate_hard as hard_mod + + self.assertFalse(hasattr(hard_mod, "hard_mode_from_env")) + self.assertFalse(hasattr(hard_mod, "HARD_MODES")) + gate = evaluate_hard_merge_gate(**_hard_kwargs()) + self.assertEqual(gate["mode"], "hard") class HardGateUnitTestCase(unittest.TestCase): @@ -200,7 +199,7 @@ class HardGateEndpointTestCase(unittest.TestCase): "POST", "/projects", {"name": "demo"}, self.owner_key) self.assertEqual(status, 201) # 默认即 hard:每个用例开始时清掉模式/保护 env,个别用例 - # 再显式设置 record/enforce/off/bogus。 + # 再显式设置旧模式值(record/enforce/off/bogus),行为一律 hard。 self._saved_env = {k: os.environ.get(k) for k in ("CLOUD_MERGE_GATE_MODE", "CLOUD_BRANCH_PROTECTION")} @@ -368,33 +367,35 @@ class HardGateEndpointTestCase(unittest.TestCase): self.assertEqual(status, 409) self.assertEqual(body["merge_gate"]["mode"], "hard") - def test_record_env_preserves_old_no_fields_behavior(self): + def test_record_env_no_longer_passes_no_fields(self): + # A1:record 环境变量在服务端被忽略,无证据调用按 hard 409, + # 不再是旧接口的“无证据放行”。 os.environ["CLOUD_MERGE_GATE_MODE"] = "record" task = self._completed_task() - status, merged = self._req( + status, body = self._req( "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) - self.assertEqual(status, 200) - self.assertEqual(merged["status"], "merged") + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") + self.assertEqual(self._status_of(task["id"]), "completed") - def test_record_env_unprotected_confirm_still_passes_with_risk(self): + def test_record_env_unprotected_confirm_no_longer_passes(self): + # A1:record 下“未保护 + 确认放行并记 merge_gate_risk”已消失: + # 确认在 hard 下不覆盖任何一项,且不再写 merge_gate_risk。 os.environ["CLOUD_MERGE_GATE_MODE"] = "record" task = self._completed_task() status, body = self._req( "POST", "/tasks/%s/merge" % task["id"], - {"branch_protection": "unprotected"}, self.owner_key) - self.assertEqual(status, 409) - task2 = self._completed_task() - status, merged = self._req( - "POST", "/tasks/%s/merge" % task2["id"], {"branch_protection": "unprotected", "merge_gate_confirm": True}, self.owner_key) - self.assertEqual(status, 200) + self.assertEqual(status, 409) + self.assertIn("branch_protection_unprotected", + body["merge_gate"]["blocks"]) audit = self.auth.list_audit(self.project["id"]) - self.assertTrue(any(a["action"] == "merge_gate_risk" - for a in audit)) + self.assertFalse(any(a["action"] == "merge_gate_risk" + for a in audit)) - def test_enforce_env_blocks_unprotected_even_with_confirm(self): + def test_enforce_env_is_ignored_and_hard_blocks_unprotected(self): os.environ["CLOUD_MERGE_GATE_MODE"] = "enforce" os.environ["CLOUD_BRANCH_PROTECTION"] = "unprotected" task = self._completed_task() @@ -402,17 +403,21 @@ class HardGateEndpointTestCase(unittest.TestCase): "POST", "/tasks/%s/merge" % task["id"], {"merge_gate_confirm": True}, self.owner_key) self.assertEqual(status, 409) - self.assertIn("branch_protection_enforce", + # hard 门禁的码,不是旧 enforce 纯函数的 branch_protection_enforce。 + self.assertIn("branch_protection_unprotected", body["merge_gate"]["blocks"]) - def test_off_env_is_explicit_opt_down_only(self): + def test_off_env_is_ignored_and_hard_blocks_unprotected(self): + # A1:off 环境变量在服务端被忽略,不再是“只记风险不阻断”。 os.environ["CLOUD_MERGE_GATE_MODE"] = "off" task = self._completed_task() - status, merged = self._req( + status, body = self._req( "POST", "/tasks/%s/merge" % task["id"], {"branch_protection": "unprotected"}, self.owner_key) - self.assertEqual(status, 200) - self.assertEqual(merged["merge_gate"]["mode"], "off") + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") + self.assertIn("branch_protection_unprotected", + body["merge_gate"]["blocks"]) def test_malformed_hard_body_returns_400(self): task = self._completed_task() diff --git a/cloud/tests/test_merge_gate_protectskill.py b/cloud/tests/test_merge_gate_protectskill.py index ff12a36..7daa94b 100644 --- a/cloud/tests/test_merge_gate_protectskill.py +++ b/cloud/tests/test_merge_gate_protectskill.py @@ -1,12 +1,16 @@ """主分支保护 + 目录隔离门禁(cloud/merge_gate)与技能工程双向断言。 -门禁口径: -- 目录隔离复用 v0.3 技能文本的 git diff --name-only 检查,不另造; - 服务端 POST /tasks/{id}/merge 在此之前只改状态、不查目录、不查保护。 -- 保护默认 record:未保护 + 无确认阻断,未保护 + 确认放行并记风险; - enforce 下未保护一律阻断;unknown 记风险、不阻断(代码无法核实 - GitHub 保护,unknown 不等于已保护,也不等于未保护)。 -- 显式确认只覆盖保护风险,绝不覆盖目录违规。 +门禁口径(纯函数部分,见 A1 声明): +- 本文件的纯函数用例测 cloud/merge_gate 本身:目录隔离复用 v0.3 + 技能文本的 git diff --name-only 检查,不另造;纯函数内 record + 默认:未保护 + 无确认阻断,未保护 + 确认放行并记风险; + enforce 下未保护一律阻断;unknown 记风险、不阻断。 +- 这些纯函数语义不是服务端行为:自 A1 起服务端 POST + /tasks/{id}/merge 一律按 hard 求值,旧模式环境变量在服务端被 + 忽略;本文件末尾的服务端用例已按新行为改写(旧 record/enforce + 服务端用例不再断言放行)。 +- 显式确认只覆盖保护风险,绝不覆盖目录违规(纯函数内;服务端 + hard 下确认不覆盖任何一项)。 技能工程: - 正文与附录双向断言:每个新关键词在正文、附录都要出现,失败 信息指明缺失的一侧(正文缺失 / 附录缺失),不是笼统的 assertIn。 @@ -260,17 +264,19 @@ class MergeEndpointGateTestCase(unittest.TestCase): conn.close() return task - def test_no_gate_fields_behaves_like_old_endpoint(self): - # 本用例测的是 record 模式的过渡语义(#209 原意):默认已是 - # hard,见 test_merge_gate_hard.py 的默认 409 用例;这里显式 - # 切 record 才沿用“无证据放行、只附带门禁信息”。 + def test_record_env_no_longer_passes_no_fields(self): + # A1:服务端不再有 record 模式。旧用例名 + # test_no_gate_fields_behaves_like_old_endpoint 已改名改语义: + # record 环境变量被忽略,无证据调用按 hard 409、状态不变。 os.environ["CLOUD_MERGE_GATE_MODE"] = "record" task = self._completed_task() - status, merged = self._req( + status, body = self._req( "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) - self.assertEqual(status, 200) - self.assertEqual(merged["status"], "merged") - self.assertIn("merge_gate", merged) + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") + _s, got = self._req("GET", "/tasks/%s" % task["id"], + key=self.owner_key) + self.assertEqual(got["status"], "completed") def test_directory_violation_blocks_without_changing_status(self): task = self._completed_task() @@ -286,28 +292,30 @@ class MergeEndpointGateTestCase(unittest.TestCase): key=self.owner_key) self.assertEqual(got["status"], "completed") - def test_unprotected_confirm_allows_and_audits_risk(self): - # record 模式语义:硬模式下未保护 + 确认仍阻断,见硬门禁测试。 + def test_record_env_unprotected_confirm_no_longer_passes(self): + # A1:旧用例 test_unprotected_confirm_allows_and_audits_risk + # 已改名改语义。record 下“未保护 + 确认放行并记 + # merge_gate_risk”的服务端路径已消失:确认在 hard 下不覆盖 + # 任何一项,且不再写 merge_gate_risk。 os.environ["CLOUD_MERGE_GATE_MODE"] = "record" task = self._completed_task() status, body = self._req( "POST", "/tasks/%s/merge" % task["id"], - {"branch_protection": "unprotected"}, - self.owner_key) - self.assertEqual(status, 409) - task2 = self._completed_task() - status, merged = self._req( - "POST", "/tasks/%s/merge" % task2["id"], {"branch_protection": "unprotected", "merge_gate_confirm": True}, self.owner_key) - self.assertEqual(status, 200) - self.assertEqual(merged["merge_gate"]["protection"], "unprotected") + self.assertEqual(status, 409) + self.assertIn("branch_protection_unprotected", + body["merge_gate"]["blocks"]) audit = self.auth.list_audit(self.project["id"]) - self.assertTrue(any(a["action"] == "merge_gate_risk" - for a in audit)) + self.assertFalse(any(a["action"] == "merge_gate_risk" + for a in audit)) - def test_enforce_env_blocks_even_with_confirm(self): + def test_enforce_env_is_ignored_and_hard_blocks(self): + # A1:旧用例 test_enforce_env_blocks_even_with_confirm 改写: + # enforce 环境变量被忽略,阻断码是 hard 门禁的 + # branch_protection_unprotected,不是纯函数的 + # branch_protection_enforce。 os.environ["CLOUD_MERGE_GATE_MODE"] = "enforce" os.environ["CLOUD_BRANCH_PROTECTION"] = "unprotected" task = self._completed_task() @@ -316,7 +324,7 @@ class MergeEndpointGateTestCase(unittest.TestCase): {"merge_gate_confirm": True}, self.owner_key) self.assertEqual(status, 409) - self.assertIn("branch_protection_enforce", + self.assertIn("branch_protection_unprotected", body["merge_gate"]["blocks"]) diff --git a/cloud/tests/test_server_merge.py b/cloud/tests/test_server_merge.py index e7acd88..9eaa7af 100644 --- a/cloud/tests/test_server_merge.py +++ b/cloud/tests/test_server_merge.py @@ -4,10 +4,10 @@ (本地或 Docker 容器内)执行,成功后再调本接口把云端 任务从 completed 改为 merged。规则:404/401/403/409 语义、maintainer 以上才能合并、审计里有 merge_task。 -门禁注意:默认模式已是 hard(见 test_merge_gate_hard.py), +门禁注意:服务端只有 hard(见 test_merge_gate_hard.py), 所以本文件里期望 200 的合并调用必须带完整硬门禁证据; 无证据调用在默认下 409(本文件末尾两项新用例钉住它), -record 下的旧行为由硬门禁测试文件覆盖。 +旧 record 下的“无证据放行”已随 A1 删模式消失,不再覆盖。 """ import http.client @@ -176,13 +176,14 @@ class MergeTestCase(unittest.TestCase): "GET", "/tasks/%s" % task["id"], key=self.owner_key) self.assertEqual(got["status"], "completed") - def test_merge_record_env_preserves_old_no_evidence_call(self): + def test_merge_record_env_no_longer_passes_no_evidence_call(self): + # A1:record 环境变量在服务端被忽略,无证据调用按 hard 409。 os.environ["CLOUD_MERGE_GATE_MODE"] = "record" task = self._completed_task() - status, merged = self._req( + status, body = self._req( "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) - self.assertEqual(status, 200) - self.assertEqual(merged["status"], "merged") + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") def test_merge_missing_and_no_key(self): status, _ = self._req( diff --git a/cloud/tests/test_v2cov_server_handlers2.py b/cloud/tests/test_v2cov_server_handlers2.py index 549cf72..5014c4a 100644 --- a/cloud/tests/test_v2cov_server_handlers2.py +++ b/cloud/tests/test_v2cov_server_handlers2.py @@ -546,13 +546,25 @@ class MergeLoopTestCase(_Base): self.assertIn("files_changed must be a list", payload["error"]) def test_merge_pushback_unavailable_409(self): + # A1 改写:旧用例以 CLOUD_MERGE_GATE_MODE=off + 空 body 走到 + # 推回检查;服务端删模式后空 body 在硬门禁体校验前已 400, + # 故改用完整硬证据 body,仍钉“推回状态不可用 → 409”这一性质。 self._project() self._task(status="completed") - h = self._h(body={}) - with mock.patch.dict(os.environ, - {"CLOUD_MERGE_GATE_MODE": "off"}), \ - mock.patch("cloud.server._pushback_block", - side_effect=RuntimeError("chain down")): + h = self._h(body={ + "files_changed": ["apps/web/a.py"], + "owned_dir": "apps/web", + "branch_protection": "protected", + "ci_status": "success", + "ci_checks": [{"name": "ci", "status": "success"}], + "required_checks": ["ci"], + "contract_version": "v2", + "contract_frozen": True, + "coordinator_approved": True, + "unresolved_review_comments": 0, + }) + with mock.patch("cloud.server._pushback_block", + side_effect=RuntimeError("chain down")): h._merge_task("u1", "t1") code, payload = self._last(h) self.assertEqual(code, 409) @@ -561,11 +573,24 @@ class MergeLoopTestCase(_Base): self.assertTrue(payload["audit_pushback"]["merge_blocked"]) def test_merge_pushback_unavailable_audit_fails_too(self): + # A1 改写:同上,off + 空 body 改为完整硬证据 body,仍钉 + # “审计本身也失败时 handler 仍回同一 409”。 # The audit best-effort except-pass: even when the audit # write itself raises, the handler still answers the same 409. self._project() self._task(status="completed") - h = self._h(body={}) + h = self._h(body={ + "files_changed": ["apps/web/a.py"], + "owned_dir": "apps/web", + "branch_protection": "protected", + "ci_status": "success", + "ci_checks": [{"name": "ci", "status": "success"}], + "required_checks": ["ci"], + "contract_version": "v2", + "contract_frozen": True, + "coordinator_approved": True, + "unresolved_review_comments": 0, + }) real_auth = server_mod._auth_store calls = {"n": 0} @@ -574,10 +599,8 @@ class MergeLoopTestCase(_Base): if calls["n"] == 1: return real_auth(db_path) raise RuntimeError("audit down") - with mock.patch.dict(os.environ, - {"CLOUD_MERGE_GATE_MODE": "off"}), \ - mock.patch("cloud.server._pushback_block", - side_effect=RuntimeError("chain down")), \ + with mock.patch("cloud.server._pushback_block", + side_effect=RuntimeError("chain down")), \ mock.patch("cloud.server._auth_store", side_effect=_auth_store): h._merge_task("u1", "t1")