From 512929c9dde959085b90e6c5c63c4cd070b22b5b Mon Sep 17 00:00:00 2001 From: Haoyu Xi Date: Wed, 21 Oct 2026 02:47:47 +0800 Subject: [PATCH] fix(gates): exempt GitHub-integrated PR merges from the master direct-push audit --- WORKMEMORY/PROJECT_OVERVIEW.md | 12 ++- WORKMEMORY/work.log | 23 +++++ tests/test_ci_hardening.py | 137 +++++++++++++++++++++++++++-- tools/check_master_commit_scope.py | 127 ++++++++++++++++++++++---- 4 files changed, 272 insertions(+), 27 deletions(-) diff --git a/WORKMEMORY/PROJECT_OVERVIEW.md b/WORKMEMORY/PROJECT_OVERVIEW.md index 5670d51f..4cff3300 100644 --- a/WORKMEMORY/PROJECT_OVERVIEW.md +++ b/WORKMEMORY/PROJECT_OVERVIEW.md @@ -30,9 +30,10 @@ - **未发版(已进 master,2026-10-20):前端单体继续解耦 + 分层闸纳管面扩展**(PR **#161** / **#164** / **#165** / **#166**,计划 `docs/plans/2026-10-19-reader-js-layer-split.md` · `2026-10-19-reader-api-layer-split.md` · `2026-10-20-cards-state-container.md`,设计稿 `docs/specs/2026-10-20-cards-state-view-decoupling-design.md`)。`reader.js` 抽出 `rd-syntax.js`(纯语法层)与 `rd-api.js`(L2 持久化层,14 个端点函数);`cards.js` 的 **15 个模块级可变状态**收进 `cd-state.js` 纯状态容器,视图层顶层 `let` **15 → 0**(首次把视图层纳入机械约束,闸规则 ⑦)。导出面全程冻结(`reader.js` 40 个具名、`cards.js` 36 个 + 1 条 barrel 逐字不变),错误轨守恒(`reader.js` 10/10、`cards.js` 12/12)。 ⚠️ **本批真正的产出不是解耦,是挖出两条与重构无关的既有静默失效**:① `ia_dom_mount_probe.mjs` 有 **4 条守卫一直是死的**(切片锚点只到函数名 ⇒ 切片里没有函数体 ⇒ 断言恒真),② 探针静态层已红时仍照样跑 `node:vm`,空源撞出的 `ReferenceError` 排在真实具名问题**后面**、把诊断引向渲染链(#166 修)。两者形态相同:**判据看着在跑,实际恒真**。纪律已写进 `docs/agents/architecture.md`「前端模块分层契约」节,动探针/守卫前必读。 ⚠️ **计划写法教训(已在计划里更正)**:变异用例的注入锚点必须按**最终状态树**普查 —— Task 1 新造的锚点钉在 `let cardSegment = "due";` 上,而那正是 Task 3 要删的行,于是两条用例挂在「锚点命中 0 次」而不是挂在闸上。另:同一主机上并行子代理曾三次落在**错误血统**的 worktree 上并据此给出与事实相反的汇报,故后续派单一律要求「先 merge 目标分支并核实 HEAD」。 -- **现测基线(2026-10-20,master `07139ac`;本次实测重算)**:全量 155 个文件 · 8 批串行全绿 = 收集 **2513**(**2511 passed + 2 skipped**)· `static/js` **36** 个模块 · `tests/test_*.py` **155** 个文件 · `tools/*.mjs` 探针 **31** 个 · `ruff` 零告警 · `mypy --strict delector tools`(**118** files)/ `mypy --follow-imports=skip tests`(**159** files)零错误 · 六道机械闸全 exit 0。 +- **现测基线(2026-10-21,分支 `fix/master-scope-gate-squash-merge`;本次实测重算)**:全量 155 个文件 · 8 批串行全绿 = 收集 **2519**(**2517 passed + 2 skipped**,净增 6 条 master-scope 健全性与变异自证测试)· `static/js` **36** 个模块 · `tests/test_*.py` **155** 个文件 · `tools/*.mjs` 探针 **31** 个 · `ruff` 零告警 · `mypy --strict delector tools`(**118** files)/ `mypy --follow-imports=skip tests`(**159** files)零错误 · 六道机械闸全 exit 0。 - 重算:`python3 -m pytest tests/ --collect-only -q | tail -1`;`ls static/js/*.js | wc -l`;`ls tests/test_*.py | wc -l`;`ls tools/*.mjs | wc -l`。 - ⚠️ 全量 MUST **分批串行**跑(本主机并行 python 会争抢数据目录守卫导致假红)。 +- **现测基线(2026-10-20,master `07139ac`;历史发版前基线)**:全量 155 个文件 · 8 批串行全绿 = 收集 **2513**(**2511 passed + 2 skipped**)· `static/js` **36** 个模块 · `tests/test_*.py` **155** 个文件 · `tools/*.mjs` 探针 **31** 个 · `ruff` 零告警 · `mypy --strict delector tools`(**118** files)/ `mypy --follow-imports=skip tests`(**159** files)零错误 · 六道机械闸全 exit 0。 - **现测基线(2026-10-19,master `4646a67` 发版基线;由本次发版实测)**:收集 **2441** 用例(**2438 passed + 3 skipped**,全量单条串行分批全绿)·`ruff check .` 零告警 · `mypy --strict delector tools`(**118** files)· `mypy --follow-imports=skip tests`(**157** files)· 入口面 `--platform win32 --strict`(**4** files)零错误 · `tools/*.mjs` 探针 **31** 个 · `static/js` **34** 个模块 · `tests/test_*.py` **153** 个文件。**六道机械闸**:`check_layer_invariants` · `check_mock_drift` · `check_silent_degradation` · `check_frontend_api_contract` · `check_frontend_layers` · `check_index_html_sync` 全 exit 0。 - 重算:`python3 -m pytest tests/ --collect-only -q | tail -1`;`python3 -m mypy --strict delector tools | tail -1`;`ls tools/*.mjs | wc -l`;`ls static/js/*.js | wc -l`。 - ⚠️ **本行已不是当前值**(v5.18.0 发版快照,收集 2441)。当前值看**上面那条「现测基线(2026-10-20)」**。v5.17.0 的 1768 passed 同理是历史快照 —— 读到时 MUST 以 2026-10-20 那条为准,MUST NOT 拿旧值做判断。 @@ -76,9 +77,12 @@ 4. **预置包 LLM gloss 富化**:阻塞于 `DEEPSEEK_API_KEY` 缺失;`import_encounter_pack` 按 `pack_id` 幂等**不更新**既有行。 5. **C 档 8 项(当前唯一待办入口)**:`docs/reviews/2026-10-16-open-items-backlog.md`。其中 **C1「裁判结论回灌(verifier-in-the-loop)」为无条件下一步**(ADR-0024 四条硬依赖:① ~~裁判 FP 先修完~~ 已确认基线完成 ② 回灌内容 MUST 过同一道适用性闸与双限截断 ③ 定三角一致性(循环裁判带 nlp / `writing_check` **不带** nlp / 回灌源)④ 阴性对照 MUST 在动产品之前先跑成基线)。⚠️ 回灌落地后,§4.3 实验的阈值与结论**只对"准入式裁判"有效** ⇒ 必须重跑或明确分域(ADR-0024 已登记资产折旧,不留给下一任发现)。 6. **递延项(未立项)**:tests `--strict`;`tools/vault-proactive-scan.py` 三条版本正则无自动化守卫;PWA 安装提示验证 / 192-512 PNG 图标;桌面单实例文件锁(pid+版本+数据目录 + `/api/health` 身份校验 + 顺延 8001–8010);发布链路 runbook(Publish 被跳过 / 孤儿 tag 的恢复路径:Vault v5.4.0 复盘 + **v5.1.1 孤儿 tag 在案**;`gh workflow run --ref ` 理论可补发但**本仓未实证**,判据只能是 release 资产清单)。 -7. **`check_master_commit_scope.py` 把 squash 合并误判成直推(当前 master CI 红的真因,非纪律违规)**:squash 合并产出的提交是**非 merge、父提交 = 上一个 master tip** ⇒ 落进 `--no-merges --first-parent` 的「直推」口径,于是它审的改动面其实是**整个 PR 的文件集**。实测:#165 与 #166 两条 PR 事件全绿、合并后 master push 的 `Master Direct-Push Scope Gate` 判红(#166 出界文件 `tests/test_exam_domain.py` · `tools/ia_dom_mount_probe.mjs`)。 - - ⚠️ **修法必须能区分「PR 合并」与「人工直推」,且 MUST NOT 退化成「凡带 `(#NNN)` 标题就放行」** —— 那等于让人在提交标题里写 `(#1)` 就能绕过整道闸(本闸存在的理由正是审计 P1-1:25 个含生产代码的提交曾直推 master)。候选判据:合并提交的 PR 编号回查 `gh`/API 的 `merge_commit_sha` 集合;或改由「同一 PR 的所有 sha 是否都经由 pull_request 事件验证过」判定。判据解析不出来时 MUST fail-closed。 - - 附带事实:该闸只看本次推送范围(`github.event.before..github.sha`),所以历史红不会阻塞后续推送 —— 但**latest master CI badge 会是红的**,得靠下一次 push 覆盖。 +7. **`check_master_commit_scope.py` 把 squash 合并误判成直推【已闭环 (2026-10-21)】**: + - 根因:`--no-merges --first-parent` 只能识别多父的 merge-commit 型合并;GitHub squash/rebase 合并产出的单父提交被误划入直推审查对象,导致 #165 / #166 在 PR CI 全绿合并后被 master 直推闸拦截(因整个 PR 改动面不在白名单)。 + - 修法(分支 `fix/master-scope-gate-squash-merge`):以 `committer == "GitHub "` 判定 GitHub 集成合并(merge / squash / rebase 三种合并方式均盖该章,本地 git commit 不会生成)。同时封死原有仅凭 `Merge pull request #...` 标题即可绕过检查的单父伪造路径(要求 `is_merge_commit` 即 ≥2 父)。 + - 实测证据:master 1315 提交中 GitHub 代写 180 条(154 merge + 26 squash/rebase 单父),其中 24 条无 `(#NNN)` 后缀(Dependabot),若按标题规则判会漏掉 92%;人类单父中标题以 `Merge ` 开头者为 0。 + - 证明:6 条 TDD 测试(新增 6 条,66 passed)+ 四重变异证明(M1 删豁免红、M2 恢复标题绕过红、M3 宽判红、M4 标题强绑定红)全部咬合;真实历史 26 条 GitHub 单父提交 100% 豁免,30 条人类改动代码提交 100% 拦截;真实历史回溯 `2e7b503..07139ac` 修复前 exit 1、修复后 exit 0。 + ## 工作方式 diff --git a/WORKMEMORY/work.log b/WORKMEMORY/work.log index 2fe622fe..e80bfbda 100644 --- a/WORKMEMORY/work.log +++ b/WORKMEMORY/work.log @@ -596,3 +596,26 @@ Caps: filesystem-read, filesystem-write, shell-exec - **6 条 `WORK_START` 现已全部配对**(2026-10-17 01:10 那条本就由本人 01:19 的 WORK_END 闭合;其余 5 条由本次代收尾闭合)。协议第 2 条「发现未闭合 WORK_START 先询问」在本案的处置:这 5 条是两天前、且分属两个其它 harness 的标记,其 DONE 事件自带验证凭据(全量 passed 数 / 逐字复刻 CI / 独立对抗评审 PASS)、PR 全部已合并(**#147 / #148 / #149 / #150 / #151**)、计划全部已进 `archive/` ⇒ 判为「标记未收尾」而非「任务未完成」,按第 7 条补记闭合、不阻塞当轮用户指令。**未代任何 agent 宣称重跑其验证**。 - **反向孤儿一条**:`2026-10-18 23:26 | hy4-preview__codebuddy | WORK_END`(CI 红灯修复 + 修 `build_index_html.py --check` 恒假红,分支 `refactor/index-html-split` / PR #159)**没有配对的 WORK_START** —— 该 agent 未写开工标记。**不伪造一条 WORK_START 去凑对称**(那是编造历史),仅在此登记,供后来者解释 `WORK_START=6 / WORK_END=7` 的计数差。 - 轮转检查:`grep -c '^### ' work.log` = **65**,`HOT_RETENTION_EVENTS` = 50 ⇒ 65 < 50×1.5 = 75,**本轮不需轮转**(按 §3 第 3 步的口径判,不是"看起来长了就搬")。 + +### 2026-10-21 03:00 | qwen3.8-flash__hermes | DONE +Caps: filesystem-read, filesystem-write, shell-exec +事件:**修复 master 直推范围机械闸误判 GitHub squash/rebase 合并**。分支 `fix/master-scope-gate-squash-merge`(独立 worktree)。 +- **根因分析**:`tools/check_master_commit_scope.py` 使用 `--no-merges --first-parent` 划定「直推审查范围」,该规则当初为防 merge-commit 被判直推而设;但 GitHub 的 squash 和 rebase 合并产出的是**单父提交**,因此被当成人类直推,检查其全量改动面,导致 #165 / #166 合并后 master push 触发 `Master Direct-Push Scope Gate` 判红。 +- **实测数据支撑(master 1315 提交)**: + - committer == `GitHub `: 180 条(154 条多父 merge,26 条单父 squash/rebase)。26 条单父中,24 条无 `(#NNN)` 后缀(Dependabot 依赖升级)。若按标题正则判会漏掉 24/26 = 92%。 + - committer == 人类: 1135 条(1113 条单父直推审查对象,22 条多父 merge)。人类单父中标题以 `Merge ` 开头者为 0。 +- **修法设计(严格 TDD)**: + 1. 新增 `_is_github_integrated(committer_name, committer_email)` 纯谓词(name 精确匹配 "GitHub",email 忽略大小写匹配 "noreply@github.com"); + 2. `run_check` 中整合 `git log -1 --format=%s%x00%cn%x00%ce%x00%P` 单次调用获取 commit 元数据,大声打印 GitHub 集成 PR 合并豁免; + 3. **封死既有单父伪造路径**:要求 `_is_merge_subject(subject) and is_merge_commit`(≥2 父才认 merge 标题),防范人类提交标题伪造成 Merge 绕过检查。 +- **TDD 与四重变异证明**: + - 新增 6 条测试,`tests/test_ci_hardening.py` **66 passed**; + - M1(删 github_integrated 豁免)⇒ A/B 变红; + - M2(退回只看 subject 忽略 is_merge_commit)⇒ D(单父伪造 merge 标题)变红; + - M3(放宽接受任意 committer)⇒ C(人类违规直推)变红; + - M4(收窄要求 subject 带 `(#NNN)`)⇒ B(Dependabot)变红。 +- **真实历史验收**: + - 修复前 `2e7b503..07139ac` exit 1,修复后 exit 0 且大声输出豁免原因; + - 真实人类文档直推 `99b678a..8bbc844` 保持 exit 0; + - 真实历史 26 条 GitHub 单父提交 100% 判定为 integrated;抽样 30 条人类代码提交 100% 判定为 not integrated。 +- **基线与门禁**:全量测试 2519 collected(2517 passed + 2 skipped);六道机械闸全部 exit 0;`ruff` / `mypy --strict delector tools` / `mypy tests` 全绿;CRLF 0 lone LF。 diff --git a/tests/test_ci_hardening.py b/tests/test_ci_hardening.py index d316fb58..e65f510c 100644 --- a/tests/test_ci_hardening.py +++ b/tests/test_ci_hardening.py @@ -1683,11 +1683,14 @@ def test_restore_req_declares_every_backup_table() -> None: class _FakeCommit(NamedTuple): - """stub 用的一条提交记录(subject + 改动面 + 是否 root)。""" + """stub 用的一条提交记录(subject + 改动面 + 是否 root / committer / parents)。""" subject: str files: tuple[str, ...] is_root: bool = False + committer_name: str = "Developer" + committer_email: str = "dev@example.com" + parents: tuple[str, ...] = ("deadbeef",) class _FakeGit: @@ -1703,10 +1706,18 @@ def __call__(self, args: Sequence[str]) -> str: return "".join(f"{sha}\n" for sha in self.commits) if args[0] == "rev-list" and "--parents" in args: sha = args[-1] - parents = () if self.commits[sha].is_root else ("deadbeef",) + c = self.commits[sha] + parents = () if c.is_root else c.parents return " ".join((sha, *parents)) + "\n" if args[0] == "log": - return self.commits[args[-1]].subject + "\n" + sha = args[-1] + c = self.commits[sha] + fmt = next((a for a in args if a.startswith("--format=")), "") + if "%x00" in fmt: + parents = () if c.is_root else c.parents + parents_str = " ".join(parents) + return f"{c.subject}\x00{c.committer_name}\x00{c.committer_email}\x00{parents_str}\n" + return c.subject + "\n" if args[0] == "diff-tree": return "".join(f"{name}\n" for name in self.commits[args[-1]].files) raise AssertionError(f"未预期的 git 调用:{args}") @@ -1738,11 +1749,49 @@ def test_check_scope_flags_out_of_allowlist_file() -> None: def test_check_scope_exempts_merge_commit() -> None: - """合并提交豁免:删掉 merge 豁免时本用例 MUST 红。""" + """真实合并提交豁免(is_merge_commit=True):删掉 merge 豁免时本用例 MUST 红。""" + module = _load_master_scope_module() + assert ( + module.check_scope( + [_ALLOWLIST_OFFENDER], + subject=_MERGE_SUBJECT, + is_merge_commit=True, + ) + == [] + ), "合并提交未被豁免:删掉 merge 豁免时本用例 MUST 红" + + +def test_check_scope_rejects_merge_subject_spoof_on_single_parent() -> None: + """合并形标题 + 单父(is_merge_commit=False)+ 出界文件 ⇒ 必须被判红。 + + 为什么严格更安全:防止直推生产代码时通过在 commit 标题写 'Merge pull request #...' + 绕过 master 直推白名单检查(关闭既有伪造路径)。 + """ + module = _load_master_scope_module() + reasons = module.check_scope( + [_ALLOWLIST_OFFENDER], + subject=_MERGE_SUBJECT, + is_merge_commit=False, + ) + assert reasons, "伪造合并标题在单父提交上必须被判红" + + +def test_check_scope_exempts_github_integrated() -> None: + """E 纯函数:github_integrated=True 时无论改动面是什么均豁免。""" module = _load_master_scope_module() - assert module.check_scope([_ALLOWLIST_OFFENDER], subject=_MERGE_SUBJECT) == [], ( - "合并提交未被豁免:删掉 merge 豁免时本用例 MUST 红" + assert ( + module.check_scope( + [_ALLOWLIST_OFFENDER], + subject="fix(x): something", + github_integrated=True, + ) + == [] ) + assert module.check_scope( + [_ALLOWLIST_OFFENDER], + subject="fix(x): something", + github_integrated=False, + ), "github_integrated=False 且出界文件必须判红" def test_check_scope_exempts_bootstrap() -> None: @@ -1789,6 +1838,82 @@ def test_cli_exit_code_violation_is_one(capsys: pytest.CaptureFixture[str]) -> N assert "c1" in err and _ALLOWLIST_OFFENDER in err +def test_cli_exempts_github_squash_merge(capsys: pytest.CaptureFixture[str]) -> None: + """A squash 形(单父 + committer GitHub + subject 带 (#NNN) + 出界文件)⇒ exit 0 且 stderr 打出豁免。""" + module = _load_master_scope_module() + git = _FakeGit( + { + "c_squash": _FakeCommit( + subject="fix(probe): stop running the vm layer after the static layer already failed (#166)", + files=(_ALLOWLIST_OFFENDER,), + committer_name="GitHub", + committer_email="noreply@github.com", + parents=("parent1",), + ) + } + ) + assert module.main(["--range", "aaa..bbb"], git=git) == 0 + err = capsys.readouterr().err + assert "c_squash" in err and "GitHub" in err and "noreply@github.com" in err + + +def test_cli_exempts_github_dependabot_merge(capsys: pytest.CaptureFixture[str]) -> None: + """B Dependabot 形(单父 + committer GitHub + subject 不带 (#NNN) + 出界文件)⇒ exit 0。证明非按标题判。""" + module = _load_master_scope_module() + git = _FakeGit( + { + "c_dep": _FakeCommit( + subject="bump foo from 1.0.0 to 1.0.1", + files=(_ALLOWLIST_OFFENDER,), + committer_name="GitHub", + committer_email="noreply@github.com", + parents=("parent1",), + ) + } + ) + assert module.main(["--range", "aaa..bbb"], git=git) == 0 + err = capsys.readouterr().err + assert "c_dep" in err and "GitHub" in err + + +def test_cli_flags_human_direct_push_violation(capsys: pytest.CaptureFixture[str]) -> None: + """C 人类单父直推 + 出界文件 ⇒ exit 1。""" + module = _load_master_scope_module() + git = _FakeGit( + { + "c_human": _FakeCommit( + subject="feat(client): direct push to master", + files=(_ALLOWLIST_OFFENDER,), + committer_name="Developer", + committer_email="dev@example.com", + parents=("parent1",), + ) + } + ) + assert module.main(["--range", "aaa..bbb"], git=git) == 1 + err = capsys.readouterr().err + assert "c_human" in err and _ALLOWLIST_OFFENDER in err + + +def test_cli_rejects_merge_subject_spoof(capsys: pytest.CaptureFixture[str]) -> None: + """D 伪造:subject 'Merge pull request #1 from evil' + 单父 + 人类 committer + 出界文件 ⇒ exit 1。""" + module = _load_master_scope_module() + git = _FakeGit( + { + "c_spoof": _FakeCommit( + subject="Merge pull request #1 from evil", + files=(_ALLOWLIST_OFFENDER,), + committer_name="Attacker", + committer_email="attacker@example.com", + parents=("parent1",), + ) + } + ) + assert module.main(["--range", "aaa..bbb"], git=git) == 1 + err = capsys.readouterr().err + assert "c_spoof" in err and _ALLOWLIST_OFFENDER in err + + def test_cli_exit_code_environment_unavailable_is_two(capsys: pytest.CaptureFixture[str]) -> None: """退出码 2 = 环境不可用(git 调用失败)。""" module = _load_master_scope_module() diff --git a/tools/check_master_commit_scope.py b/tools/check_master_commit_scope.py index bd2df22b..c989a894 100644 --- a/tools/check_master_commit_scope.py +++ b/tools/check_master_commit_scope.py @@ -17,17 +17,38 @@ 「sha + 出界文件 + 命中规则」。(为什么必须带 ``--first-parent``:见 ``_list_commits`` —— 不加会把每次 PR 合并的分支提交误判为直推违规。) -两条豁免出口(**绝不静默**,一律打印明确警告后再通过): +三条豁免出口(**绝不静默**,一律打印明确警告/信息后再通过): 1. **bootstrap** —— 仓库最初的提交(root commit)或显式 ``--bootstrap``:历史回溯 / 首次 导入时整树入仓,若不豁免会全红。 2. **范围不可判定** —— 首次推送(``before`` 全零 / 为空)时范围无从谈起。 +3. **GitHub 集成 PR 合并** —— GitHub 网页/集成合并的提交(committer == ``GitHub ``)。 + 覆盖 squash 与 rebase 方式合并进 master 的单父 mainline 提交。 + +实测数据支撑与可伪造局限说明 +---------------------------- +master 历史 1315 提交实测(committer 干净二分): +- committer == ``GitHub ``:180 条(154 条 ≥2 父的 merge commit, + 26 条单父 squash/rebase 提交)。其中 24 条无 ``(#NNN)`` 标题后缀(Dependabot 依赖升级)。 + 按 committer 身份判定可覆盖全部 26 条;若仅按标题正则判会漏掉 24/26 = 92%。 +- committer == 人类:1135 条(1113 条单父直推审查对象,22 条多父 merge)。人类单父中标题以 + ``Merge `` 开头者为 0 条。 + +⚠️ 诚实局限说明: +committer 身份完全由 git 客户端设定,操作者可用 +``GIT_COMMITTER_NAME=GitHub GIT_COMMITTER_EMAIL=noreply@github.com`` +在本地伪造身份绕过本闸。本闸的目标是防御**事故类直推**(习惯性或无意识的直推 —— 如审计 P1-1 +指出的 25 个包含生产代码的直推提交,且曾引发 f49d359 事故),而非防范蓄意内鬼攻击。 +豁免一律在大声打印(stderr 打出 sha、committer 姓名与邮箱),因而代码审查与审计日志完全可见, +这是本方案被接受的理由,**不是**其不可伪造的证明。 +另外,rebase 型合并覆盖是按 committer 身份**构造性成立**(那 24 条无 ``(#NNN)`` 的 +Dependabot 样本是它的经验面),**不是**实测到一次手写代码的 rebase 合并。 ⚠️ 纪律(计划 Global Constraints):机械闸 MUST **fail-loud 且可解释**,MUST NOT 出现 「拦不住真实情况却让人以为拦住了」—— 故任何豁免都在 stdout/stderr 留下明确字样。 退出码 ------ -0 = 通过(含两条豁免的 warn-through);1 = 有违规;2 = 环境不可用(git 不可用 / 不是 +0 = 通过(含三条豁免的 warn-through);1 = 有违规;2 = 环境不可用(git 不可用 / 不是 仓库 / 范围无法解析等)。照 ``tools/bench_judge_fp.py`` 的 0/1/2 惯例。 只读:仅调用 ``git rev-list`` / ``git log`` / ``git diff-tree``(全只读)。 @@ -41,7 +62,7 @@ import re import subprocess import sys -from typing import Callable, Optional, Sequence, TextIO +from typing import Callable, NamedTuple, Optional, Sequence, TextIO #: master 直推白名单:簿记 + 文档 + 版本面(与审计 P1-1 的建议清单逐项一致)。 #: 语义:以 ``/`` 结尾 = 目录前缀;否则 = 精确文件名(亦接受 ``*`` 通配 —— 便于用 ``**`` @@ -60,6 +81,10 @@ #: git 调用超时(秒):新增进程链 MUST 有界(计划「进程纪律」)。 GIT_TIMEOUT_SECONDS = 60 +#: GitHub 集成合并(网页 PR merge/squash/rebase)盖戳的 committer 身份。 +GITHUB_COMMITTER_NAME = "GitHub" +GITHUB_COMMITTER_EMAIL = "noreply@github.com" + #: 合并提交 subject 形态。CLI 已用 ``git rev-list --no-merges`` 过滤,这里做纵深防御 #: (并让「合并提交豁免」在纯函数层可单测 —— 计划 Step 4 变异自证要求删豁免即红)。 _MERGE_SUBJECT_RE = re.compile( @@ -67,6 +92,17 @@ ) +def _is_github_integrated(committer_name: str, committer_email: str) -> bool: + """判断 committer 是否为 GitHub 集成操作(网页 squash / rebase / merge)。 + + 姓名精确匹配 "GitHub",邮箱大小写不敏感匹配 "noreply@github.com"。 + """ + return ( + committer_name == GITHUB_COMMITTER_NAME + and committer_email.strip().lower() == GITHUB_COMMITTER_EMAIL.lower() + ) + + class GitError(RuntimeError): """git 调用失败(命令缺失 / 非零退出 / 超时)⇒ CLI 退出码 2。""" @@ -102,15 +138,22 @@ def check_scope( *, subject: str, bootstrap: bool = False, + github_integrated: bool = False, + is_merge_commit: bool = False, ) -> list[str]: """判定单个提交的改动面是否越界;返回违规原因列表(空 = 通过)。 - ``bootstrap=True`` 表示「该提交是仓库最初提交」—— 整树入仓,豁免(见模块头)。 - ``subject`` 命中合并提交形态时豁免(合并提交的改动面语义与普通提交不同)。 + 豁免次序: + 1. ``bootstrap=True``:仓库最初提交或显式声明 bootstrap(整树入仓,豁免)。 + 2. ``github_integrated=True``:GitHub 集成 PR 合并(committer 为 GitHub )。 + 3. ``_is_merge_subject(subject) and is_merge_commit``:真实合并提交(≥2 父)且标题符合 merge 格式。 + ⚠️ 必须同时满足 is_merge_commit,关闭单父伪造 merge 标题绕过检查的路径。 """ if bootstrap: return [] - if _is_merge_subject(subject): + if github_integrated: + return [] + if _is_merge_subject(subject) and is_merge_commit: return [] allowlist_summary = " · ".join(MASTER_DIRECT_ALLOWLIST) reasons: list[str] = [] @@ -162,10 +205,50 @@ def _list_commits(git: GitRunner, range_spec: str) -> list[str]: return [line.strip() for line in out.splitlines() if line.strip()] +class CommitMeta(NamedTuple): + """单条提交的元数据(进程纪律:一次 git log 获取全部字段)。""" + + subject: str + committer_name: str + committer_email: str + parents: tuple[str, ...] + + @property + def is_root(self) -> bool: + """是否为仓库最初提交(0 父)。""" + return len(self.parents) == 0 + + @property + def is_merge_commit(self) -> bool: + """是否为合并提交(≥2 父)。""" + return len(self.parents) >= 2 + + +def _commit_meta(git: GitRunner, sha: str) -> CommitMeta: + """一次 git log 获取提交的 subject / committer / parents。 + + 分隔符 MUST 用 ``%x00``(NUL),防止非 ASCII subject / committer 姓名挤错位。 + """ + raw = git(["log", "-1", "--format=%s%x00%cn%x00%ce%x00%P", sha]) + lines = raw.splitlines() + line = lines[0] if lines else "" + parts = line.split("\x00") + subject = parts[0] if len(parts) > 0 else "" + committer_name = parts[1] if len(parts) > 1 else "" + committer_email = parts[2] if len(parts) > 2 else "" + parents_raw = parts[3] if len(parts) > 3 else "" + parents = tuple(parents_raw.split()) if parents_raw.strip() else () + return CommitMeta( + subject=subject, + committer_name=committer_name, + committer_email=committer_email, + parents=parents, + ) + + def _commit_subject(git: GitRunner, sha: str) -> str: - """单条提交的 subject(``git log -1 --format=%s``)。""" - lines = git(["log", "-1", "--format=%s", sha]).splitlines() - return lines[0].strip() if lines else "" + """单条提交的 subject(兼容保留)。""" + return _commit_meta(git, sha).subject def _commit_files(git: GitRunner, sha: str) -> list[str]: @@ -183,9 +266,8 @@ def _commit_files(git: GitRunner, sha: str) -> list[str]: def _is_root_commit(git: GitRunner, sha: str) -> bool: - """root commit(无父)判定:``rev-list --parents -n 1`` 只回一个 token。""" - tokens = git(["rev-list", "--parents", "-n", "1", sha]).split() - return len(tokens) <= 1 + """root commit 判定(兼容保留)。""" + return _commit_meta(git, sha).is_root def _range_before(range_spec: str) -> Optional[str]: @@ -238,22 +320,33 @@ def run_check( violations: list[tuple[str, str, list[str]]] = [] for sha in shas: try: - subject = _commit_subject(runner, sha) + meta = _commit_meta(runner, sha) files = _commit_files(runner, sha) - is_root = _is_root_commit(runner, sha) except GitError as exc: print(f"[master-commit-scope] 环境不可用:{exc}", file=stderr) return 2 - if is_root: + if meta.is_root: print( f"[master-commit-scope] {sha} 是仓库最初提交(root commit)⇒ bootstrap 豁免" f"(整树入仓,非违规)。", file=stderr, ) continue - reasons = check_scope(files, subject=subject) + is_gh = _is_github_integrated(meta.committer_name, meta.committer_email) + if is_gh: + print( + f"[master-commit-scope] {sha} committer 为 {meta.committer_name} <{meta.committer_email}>" + f" ⇒ GitHub 集成 PR 合并(squash/rebase)豁免(非直推,已过 PR 门禁)。", + file=stderr, + ) + reasons = check_scope( + files, + subject=meta.subject, + github_integrated=is_gh, + is_merge_commit=meta.is_merge_commit, + ) if reasons: - violations.append((sha, subject, reasons)) + violations.append((sha, meta.subject, reasons)) if not violations: print(