From fd1737b0a841475de5ba6f0d6a997d19848391e2 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 11 Sep 2026 21:36:52 +0800 Subject: [PATCH 1/5] emrg: check a PR's base can still reach master before merging it A PR whose `base` is another *feature branch* passes every gate this repo has: CI is green (it runs against the base), the vote helper counts its LGTMs (it never reads the base), the PR is MERGEABLE - and `gh pr merge` succeeds. It still lands **nothing on master**, because `gh pr merge` merges into the PR's *base branch*, and this repo squash-merges: after the parent lands, the parent branch is never an ancestor of master, so the child's merge commit goes to a dead end. Measured 2026-09-11 (`cyc20260911-210746`): #1148 was based on `feature/conflict-classifier-multiline-count`, the branch of #1147. #1147 was squash-merged as `25904b6`, so that base is not an ancestor of master. #1148 carries the fix to the classifier master had just received; approved as it stood, the fix would have been merged into the dead branch, master's classifier would have kept answering `KEEP BOTH (concatenate)` on the very blocks the PR fixes, and the queue would have shown a MERGED PR with green CI and counting votes that changed nothing. Retargeting #1148 to master fixed that instance; this tool makes the shape visible instead of depending on a cycle noticing by hand. The predicate is deliberately not "base == master". A stacked PR whose parent is still in flight is legitimate - it is the workflow #1149 exists to support - so the question asked is the decidable one: **can a merge into this base still reach master?** `identical`/`behind` from the compare API means yes; a deleted base, or a base whose head is `ahead`/`diverged` from master, means the PR is on a dead end and must be retargeted (`gh api -X PATCH repos///pulls/ -f base=master`). Exit codes mirror the other check tools: 0 all reachable / 1 a PR is on a dead end / 2 could not be determined - an unreadable state is never reported as OK, since the failure it would hide is precisely the silent one. Verified: `uv run pytest tests/ -q` -> 1497 passed, 1 skipped (1498 collected, matching the doc); `check-doc-count.py` -> OK; `check-node-test-count.py` -> OK. The new suite is mutation-tested in three places (widening the compare statuses to "not ahead", forcing the master shortcut off, and making the reachability probe always return True each turn a test red). --- Agent.md | 3 +- scripts/check-pr-base.py | 225 ++++++++++++++++++++++++++++++++++++ tests/test_check_pr_base.py | 179 ++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 scripts/check-pr-base.py create mode 100644 tests/test_check_pr_base.py diff --git a/Agent.md b/Agent.md index 22fd8032..439abd15 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1483) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1498) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) @@ -128,6 +128,7 @@ Release bump: `python3 scripts/bump-version.py ` — 一次改齐 8 处 Doc count sync: `uv run --no-sync python3 scripts/check-doc-count.py [--write|--dry-run|--resolve-conflict]` — 测量当前树的 pytest 收集数并校验 Agent.md 的 Python 测试数(宿主侧自检,与 CI 的 test_python_count_matches_docs 对称);`--write` 仅单 token 替换那个数字(前后遮罩对比,其余字节不变),锚点缺失/出现多次/收集失败均 fail-loud(exit 2)。`--resolve-conflict` 专治合并冲突态:冲突块的**两侧按构造都是陈旧的**,`--write` 会(正确地)拒绝猜哪一侧为真;该模式去掉冲突标记后**在合并后的树上重新测量**写入,绝不选边——实测两侧写 1335/1337 而合并树实收 1339。冲突出现在计数行以外的位置即拒绝(不是什么通用「删标记」按钮)。用途:合并冲突解决后一条命令同步(#1119/#1120/#1121/#1122 四次冲突均在这一行;2026-09-11 #1130 落地后三个 PR 转 DIRTY,逐个解冲突都靠这条) Node count sync: `uv run --no-sync python3 scripts/check-node-test-count.py [--write|--dry-run]` — 直接问**真实运行器**(vitest / node --test)并校验 Agent.md 的 Renderer 与 GUI 两个总数:`tests/test_doc_counts.py` 只能**静态**数 `it(`/`test(` 定义(pytest 作业没有 node_modules),而静态计数只是运行器的**模型**——R2254(445→448)、#1120(同 stem 文件整份被吞)、#1125(`it.each`/`test.skip` 正则看不见)三次都是模型与实践脱节。GUI 侧按 CI 环境(EMRG_SKIP_INTEGRATION=1)运行后减去 1 个模块级 `skip()` 原因条目(该条目数会先断言为 1,形状变了就停手而不是报个看着像对的数)。缺 node_modules 即报该原因,绝不报假数 Vote count: `uv run --no-sync python3 scripts/check-vote-count.py ...` — 合并前数**仍然算数**的 LGTM 票,而不是评论里看得见的 ✅ 行数。三条规则让手工计数每次都错:① **票早于 head push 即作废**(rebase/解冲突推新 head 后,旧 ✅ 说的是已经不存在的 commit,而评论历史照样显示五六行 ✅,PR 看着能合实则零票——2026-09-11 实测 #1133/#1134/#1136/#1137 各显示 4-6 张 LGTM,解锁后**有效票全为 0**);② **❌ 重置计数**(三次 ✅ 后一个 needs-fix,再一个 ✅ 只算一票);③ **同一周期只算一票**(否则一个周期独自把 PR 送过门槛)。判票读正文首行**首个内容字符**(`gh pr review --comment` 让 GitHub 把 ✅ 和 ❌ 都记成 `COMMENTED`,state 字段在这里毫无用处);该字符**透过 markdown 装饰**读取(`**❌`/`- ❌`/`> ❌`/`## ❌`/`1. ❌` 都算,装饰字符本身不可能是判决),并**由它决定整行**——✅ 开头即使后文提到 ❌ 仍是批准(那些提及是在说「没有 ❌」,说法是开放集合),整行扫描只作为无开头标记时的兜底(散文式「Not LGTM」为否决、「Result: ❌ needs fix」为否决)。周期号从正文里取(`cyc20260911-091230`)。push 时间取**该 SHA 上最早一次 workflow run 的创建时间**(就是 GitHub 收到 push 事件的时刻);没有 run 时退回 commit 日期并**在输出里注明**(commit 日期可能早于 push,是乐观方向,不许默默采信)。exit 0 每张 PR 都够票 / 1 有 PR 缺票 / 2 问不出来(gh 失败、响应不可解析)——问不出来的问题绝不报数。评论**逐页取全**(reviews 端点默认只给 30 条且**按时间正序**,超限时丢掉的正是**最新的**票,也就是唯一算数的那几张;#1134 解锁一次就有 7 条评论),并在本地按时间排序(「❌ 重置计数」和「同周期只算一票」都是**位置相关**的规则,依赖服务端返回顺序等于把判决权外包)。两个自暴露的坑:① gh 的 `--jq` **后者覆盖前者**,helper 若自己追加 `--jq` 会吃掉调用方投影,字段名退回原名、`at` 读成空串,而 `"" <= push_time` 恒真 ⇒ **全部票作废**、有 2 票的 PR 显示 0/3(计数方向偏「安全」,没人会去查);故 helper 只管 `--paginate`,投影归调用方,并对载荷形状**运行时断言**(缺 `at` 即 exit 2,不报数) +PR base check: `uv run --no-sync python3 scripts/check-pr-base.py [--repo owner/name] [PR...]` — 合并前查每张 open PR 的 **base 分支是否还能到达 master**。`gh pr merge` 合的是 PR 的 **base 分支**,而 stacked PR(base 是另一条 feature 分支)的 base 在本仓库被 squash 合并后**永远不是 master 的祖先**:合进去只会落到那条死分支上,master 一无所获,而 PR 状态显示 MERGED、CI 双绿、票数也照常累计——三个现有闸门全都放行。实测(cyc20260911-210746):#1148 的 base 是 #1147 的分支,而 #1147 已 squash 合入 master,若照原样批准,这个「让 master 的分类器不再重复内容」的修复就会合进死分支,队列里留一张 MERGED 却什么都没改变的 PR。判据不是「base 是不是 master」(stacked PR 在父分支未合并时是正常且有用的工作流,正是 #1149 要服务的情形),而是**「合进这个 base 还能不能到 master」**:base 已在 master 上 ⇒ OK;base 已删除或其 head 不在 master ⇒ DEAD(须先 retarget);读法用 compare API 的 status(`identical`/`behind` = 在 master 上,`ahead`/`diverged` = 不在),四个状态都有测试钉住(把判据放宽成「不是 ahead 就算 OK」会让 `diverged` 蒙混过关)。exit 0 全部可达 / 1 有 PR 停在死路 / 2 问不出来(gh 失败、响应不可解析)——问不出来绝不报 OK。retarget 命令:`gh api -X PATCH repos///pulls/ -f base=master`(`gh pr edit --base` 会因 Projects-classic 弃用报错)。 Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) Git-over-https push 兜底: `python scripts/push-branch-from-api.py --branch feature/x [--ref HEAD] [--force]` — 同一宕机场景下的 push 方向(#988 配对):从本地 ref 沿一父链找到远端基点(已有分支头或首个远端已知祖先),自底向上上传 blobs(原始字节)/trees(`git mktree` 语义复算)/commits(结构化创建,author/committer 携带原始 +0800 偏移、消息去尾随换行——GitHub 规范化行为),更新远端 ref 后把本地分支 ref 重写为远端 sha 并 `git diff` 验证内容一致;失败即止不触碰 refs(hermetic 测试经忠实假 API 验证字节级 sha 一致) Conflict triage: `python3 scripts/classify-conflict.py ... [--all]` — 合并冲突的**分类器(只报告,绝不改文件,exit 1 = 有需要人判的块)**,把「两边都动了同一个文件」拆成五种可判定的形状并给出证据支持的解法:`count-line`(两侧只差一个**带括号的计数**数字 ⇒ 必须在合并后的树上**测量**,永不选边,交给 `check-doc-count.py --resolve-conflict`)、`duplicate`(两侧**声明符号**互为真超集**且共有符号的正文全同** ⇒ 取超集那侧)、`disjoint`(声明符号不相交 ⇒ **两边都保留**)、`overlapping`(同名符号,或超集但共有符号正文不同,或单行无判据 ⇒ 必须人读)、`identical`。存在理由(cyc20260911-112155 实测):同一轮里同一形状需要**相反**的解法——#1136 的分支带着 #1134 的**未合并副本**(分支基于 #1134 而后者已被 squash,祖先关系不可见),master 是严格超集故「取 theirs」正确;#1140 两侧是**不相交新增**,取任何一侧都会静默丢掉另一侧 4 个探针,唯一正解「两边都留」。一条 `--theirs` 对前者对、对后者错。两条判别轴都是被实测打出来的:① 必须是**声明符号**而非文本行——初版按内容行比较,因两侧都含 ` """` 与 ` )` 这种样板行,把两个真实冲突全判成 `overlapping`;② 名字子集**不等于**内容包含——只比名字时「master 声明了分支所有名字」被读成「master 含分支全部内容」,而两侧同名 `test_alpha` 正文不同时取超集会**静默丢掉分支对它的修改**(正是本工具要防的数据丢失);③ `count-line` 必须限定为**带括号的计数**,否则 `x = compute(1)` vs `compute(2)` 这种纯代码差异也会被答「测量、永不选边」并 exit 0,把唯一该人看的情况关掉。三条均已钉为变异验证的回归测试(cyc20260911-120717)。**第四类:读不了的布局即拒绝**(2026-09-11 实测)——`merge.conflictStyle = diff3` 会在 ours 与分隔符之间插入 `||||||| ` 段,而 `CONFLICT_BLOCK` **照样匹配**(把 base 段吞进 ours),于是判出来的两侧是(ours+base)vs theirs:一段 `disjoint` 被判成 `KEEP BOTH` 并 exit 0,而「都保留」恰好会把**谁都不想要的 base 副本**拼回去;本仓库的真实形态(Agent.md 计数行)更隐蔽——判成 `duplicate - ours is a strict superset - take OURS`,因为 base 段(带着**陈旧**计数的第三份拷贝)被算成 ours 多出来的行。故先于解析检查该标记并报 `unparsed-layout` + exit 1,与 `check-doc-count.py` 对同一布局的具名拒绝一致(那条路是 read-only 无损失,只会不必要地拦住一个本可解的 PR;这条路的错误建议会让解冲突的人**删掉自己的改动**,代价不对称)。**第五类:跨行聚合与「同一段落的两个修订」**(cyc20260911-190629 实测,两个缺陷都在**本工具自己**里,且都靠「拿全部真实冲突块做差分」而非手写 fixture 才暴露出来——手写 fixture 只会覆盖我本来就相信的形状):① 一条 `count-line` 原先是「两侧各一行」,但 51 次改 Agent.md 的提交里有 **3 次一次动掉 2 个以上计数**,git 会把它们并成**一个**块,该块因此落到内容行兜底、被判「两侧无共同行 ⇒ KEEP BOTH(拼接)」并 exit 0——拼接恰好把每个计数行**各留两份**,正是本仓库 `_duplicated_count_line_kinds` 守卫要拒的状态;现改为**对齐的、逐对只差数字的**块一律 `count-line`(任意长度,且每对都要满足带括号计数)。② 「两侧无共同行」**不等于**「各自新增」——同一段落的新旧两个修订永远不相等。当前 #1140 的 Agent.md 真实块里,ours 的两行段落正是 master 两行的**严格前缀**(890 vs 539、601 vs 471 字符),兜底判 KEEP BOTH 会同时留下陈旧与当前两份段落;现改为见到严格前缀关系即报 `overlapping` 交人读(前缀证据弱于符号子集,误差不对称:升级只是多读一次,「取 theirs」会静默丢一行)。两个修复各配变异验证(还原旧行为即测试变红)。 diff --git a/scripts/check-pr-base.py b/scripts/check-pr-base.py new file mode 100644 index 00000000..bbda88bb --- /dev/null +++ b/scripts/check-pr-base.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Report PRs whose base branch is not on the path to master. + +The class this exists for +------------------------ +A PR whose `base` is another *feature branch* passes every check this repo has: + +* CI is green (a stacked PR runs against its base, and #1149 now gives it runs at + all); +* the vote helper counts its LGTMs normally - nothing in it looks at the base; +* `gh pr merge` succeeds. + +And it still lands **nothing on master**. `gh pr merge` merges into the PR's +*base branch*, which for a stacked PR is the parent *branch* - and this repo +squash-merges its PRs, so the parent branch is never an ancestor of master +afterwards. The merge commit goes to the parent branch, master never sees the +change, and the PR is marked MERGED. + +Measured 2026-09-11 (`cyc20260911-210746`): #1148 was based on +`feature/conflict-classifier-multiline-count`, the branch of #1147. #1147 was +squash-merged as `25904b6`, so that base branch is not an ancestor of master and +its content on master came from the squash - not from the branch. #1148 carried +the *fix* to the classifier master had just received; had it been approved as it +stood, the fix would have been merged into a dead branch, master's classifier +would have kept answering `KEEP BOTH (concatenate)` on the very blocks the PR +fixes, and the queue would have shown a MERGED PR with three green votes that +changed nothing. The condition is invisible in every dashboard: the PR is +MERGEABLE, CI is green, and the votes count. + +Why the base being non-master is not automatically fatal +-------------------------------------------------------- +A stacked PR is a legitimate, useful workflow *while the parent is in flight* - +that is the whole point of #1149, which made CI run for non-master bases. The +question is not "is the base master", it is **"can a merge into this base still +reach master"**, and that is decidable: + +* the base branch's head is **on** master (or is master) -> yes; the stacked PR is + fine and will arrive when the parent lands. `OK`. +* the base branch no longer exists, or its head is not on master -> **no**; the PR + is based on a dead end. `DEAD`. +* the parent PR was already merged -> nothing can carry the child over. `DEAD`. + +Distinguishing these is what keeps the check from being noise, since a stacked +PR on a live parent is normal and is the state #1149 exists to serve. + +Exit codes +---------- +0 every open PR's base can still reach master (or is master) +1 at least one open PR is based on a dead end - retarget it before merging +2 the question could not be answered (gh failed, response unparseable) + +Never reports a count it could not obtain: a check that guesses "OK" when it +could not read the state is worse than no check, because the failure it hides is +exactly the silent one. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys + +MASTER = "master" + + +def _gh(*args: str) -> str: + """Run `gh`, failing loud rather than guessing.""" + proc = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if proc.returncode != 0: + raise RuntimeError( + f"gh failed (rc={proc.returncode}): gh {' '.join(args)}\n" + f"{proc.stderr.strip()}" + ) + return proc.stdout + + +def _gh_json(*args: str) -> object: + return json.loads(_gh(*args)) + + +def _open_prs(repo: str | None) -> list[dict]: + """Every open PR with the fields this check needs.""" + args = [ + "pr", + "list", + "--state", + "open", + "--limit", + "100", + "--json", + "number,baseRefName,headRefName,state", + ] + if repo: + args += ["-R", repo] + payload = _gh_json(*args) + if not isinstance(payload, list): + raise RuntimeError(f"expected a list of PRs, got {type(payload).__name__}") + for pr in payload: + for field in ("number", "baseRefName"): + if field not in pr: + raise RuntimeError(f"PR entry missing {field!r}: {pr}") + return payload + + +def _repo_path(repo: str | None) -> str: + """`owner/name` - the caller's, or gh's placeholder for the current repo.""" + return repo if repo else "{owner}/{repo}" + + +def _branch_heads(repo: str | None) -> dict[str, str]: + """branch name -> head sha, for every branch on the remote.""" + proc = subprocess.run( + ["gh", "api", f"repos/{_repo_path(repo)}/branches", "--paginate"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if proc.returncode != 0: + raise RuntimeError( + f"gh failed (rc={proc.returncode}): listing branches\n" + f"{proc.stderr.strip()}" + ) + # --paginate concatenates page arrays, which is not valid JSON as a whole; + # the endpoint returns an array per page, so parse per document if it parses, + # else fall back to line-delimited objects. + try: + payload = json.loads(proc.stdout) + except json.JSONDecodeError: + payload = [json.loads(line) for line in proc.stdout.splitlines() if line.strip()] + if not isinstance(payload, list): + raise RuntimeError("branch listing was not a list") + heads = {} + for entry in payload: + name = entry.get("name") + sha = (entry.get("commit") or {}).get("sha") + if name and sha: + heads[name] = sha + return heads + + +def _ref_is_on_master(sha: str, repo: str | None) -> bool: + """True when `sha` is an ancestor of (or equal to) master's head.""" + data = _gh_json("api", f"repos/{_repo_path(repo)}/compare/{MASTER}...{sha}") + status = data.get("status") + if status is None: + raise RuntimeError(f"compare response has no status: {data}") + # `identical` and `behind` mean the commit is on master's history; `ahead` + # and `diverged` mean it is not. + return status in ("identical", "behind") + + +def classify_base(base: str, branches: dict[str, str], repo: str | None) -> tuple[str, str]: + """`(verdict, why)` for one PR's base branch.""" + if base == MASTER: + return ("OK", "base is master") + if base not in branches: + return ( + "DEAD", + f"base branch {base!r} no longer exists on the remote, so a merge " + "would have nowhere to land and could never reach master", + ) + sha = branches[base] + if _ref_is_on_master(sha, repo): + return ("OK", f"base {base!r} ({sha[:8]}) is already on master") + return ( + "DEAD", + f"base {base!r} ({sha[:8]}) is not on master - it is a dead end. This is " + "the squash-merge shape: if the parent PR was squash-merged, the branch " + "was never an ancestor of master and merging into it lands nothing. " + "Retarget this PR to master before approving it", + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Report PRs whose base branch cannot reach master." + ) + parser.add_argument("--repo", help="owner/name (default: the current repo)") + parser.add_argument( + "prs", nargs="*", type=int, help="only these PR numbers (default: all open)" + ) + args = parser.parse_args(argv) + + try: + prs = _open_prs(args.repo) + branches = _branch_heads(args.repo) + except (RuntimeError, json.JSONDecodeError) as exc: + print(f"cannot determine PR bases: {exc}", file=sys.stderr) + return 2 + + if args.prs: + wanted = set(args.prs) + prs = [p for p in prs if p["number"] in wanted] + + dead = 0 + for pr in sorted(prs, key=lambda p: p["number"]): + try: + verdict, why = classify_base(pr["baseRefName"], branches, args.repo) + except (RuntimeError, json.JSONDecodeError) as exc: + print(f"#{pr['number']} UNKNOWN base={pr['baseRefName']!r}: {exc}") + return 2 + mark = "OK " if verdict == "OK" else "DEAD " + print(f"#{pr['number']} {mark} base={pr['baseRefName']!r} - {why}") + if verdict == "DEAD": + dead += 1 + + if dead: + print( + f"\n{dead} open PR(s) are based on a branch that cannot reach master. " + f"Retarget with: gh api -X PATCH repos///pulls/ -f base=master" + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_pr_base.py b/tests/test_check_pr_base.py new file mode 100644 index 00000000..d7fb5531 --- /dev/null +++ b/tests/test_check_pr_base.py @@ -0,0 +1,179 @@ +"""Tests for scripts/check-pr-base.py - PRs based on a branch that cannot reach master. + +The defect (`cyc20260911-210746`): #1148's base was the branch of #1147, which was +*squash*-merged. The base branch therefore is not an ancestor of master, and +`gh pr merge` - which merges into the PR's **base branch** - would have landed the +fix in a dead end. The PR would have read MERGED with three valid ✅ votes while +master's classifier kept answering `KEEP BOTH (concatenate)` on the blocks the PR +fixes. Every existing gate passes this state: CI is green (against the base), the +vote helper counts the votes (it never reads the base), and the PR is MERGEABLE. + +The tests below pin the *classification*, with the two shapes that must come out +differently - which is the whole reason the check cannot just be "base == master": + +* a stacked PR whose parent is still live on master is **fine** (that is the + workflow #1149 exists to serve, and flagging it would make the check noise); +* a base that is gone, or whose head is not on master after a squash merge, is a + dead end and must be flagged. + +The measurement predicate itself is verified: `_ref_is_on_master` reads the +compare API's `status`, and the fixtures cover every status GitHub can return - +including the two that mean "on master" (`identical`, `behind`), because a version +that treated any non-`ahead` status as fine would pass a `diverged` base. +""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "scripts" / "check-pr-base.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("check_pr_base", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def mod(): + return _load() + + +class TestCompareStatusIsReadCorrectly: + """`_ref_is_on_master` must read the compare status, not infer from a count.""" + + @pytest.mark.parametrize( + "status,expected", + [ + ("identical", True), # the commit *is* master's head + ("behind", True), # master has moved on; the commit is its ancestor + ("ahead", False), # the commit is not in master at all + ("diverged", False), # forked off before master's tip + ], + ) + def test_every_compare_status_maps_to_the_right_answer( + self, mod, monkeypatch, status, expected + ) -> None: + def fake_gh_json(*args): + assert any("compare/master..." in a for a in args), args + return {"status": status} + + monkeypatch.setattr(mod, "_gh_json", fake_gh_json) + assert mod._ref_is_on_master("deadbeef", "owner/repo") is expected + + def test_a_missing_status_is_an_error_not_a_pass(self, mod, monkeypatch) -> None: + """Unreadable state must fail loud - guessing 'OK' hides the silent case.""" + monkeypatch.setattr(mod, "_gh_json", lambda *a: {"commits": []}) + with pytest.raises(RuntimeError, match="no status"): + mod._ref_is_on_master("deadbeef", "owner/repo") + + +class TestClassifyBase: + def test_master_is_always_ok(self, mod) -> None: + verdict, why = mod.classify_base("master", {}, "owner/repo") + assert verdict == "OK" + assert "master" in why + + def test_a_deleted_base_branch_is_dead(self, mod) -> None: + """No branch at all: a merge would have nowhere to land.""" + verdict, why = mod.classify_base("feature/gone", {}, "owner/repo") + assert verdict == "DEAD" + assert "no longer exists" in why + + def test_a_base_not_on_master_is_dead(self, mod, monkeypatch) -> None: + """The measured #1148 shape: the branch exists, its head is not on master.""" + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: False) + verdict, why = mod.classify_base( + "feature/conflict-classifier-multiline-count", + {"feature/conflict-classifier-multiline-count": "fb5a4e99" * 5}, + "owner/repo", + ) + assert verdict == "DEAD" + assert "not on master" in why + + def test_a_live_stacked_base_is_ok(self, mod, monkeypatch) -> None: + """A stacked PR whose parent is still on master is legitimate, not a defect. + + This is the other half of the discrimination: if everything non-master were + flagged, the check would cry wolf on the normal stacked workflow. + """ + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: True) + verdict, why = mod.classify_base( + "feature/live-parent", {"feature/live-parent": "abc12345" * 5}, "owner/repo" + ) + assert verdict == "OK" + assert "already on master" in why + + +class TestMainExitCodes: + def _wire(self, mod, monkeypatch, prs, branches, on_master): + monkeypatch.setattr(mod, "_open_prs", lambda repo: prs) + monkeypatch.setattr(mod, "_branch_heads", lambda repo: branches) + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: on_master(sha)) + + def test_exit_1_when_a_pr_is_based_on_a_dead_end(self, mod, monkeypatch) -> None: + prs = [ + {"number": 1148, "baseRefName": "feature/dead", "headRefName": "h1"}, + {"number": 1151, "baseRefName": "master", "headRefName": "h2"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/dead": "s1"}, lambda sha: False) + assert mod.main(["--repo", "owner/repo"]) == 1 + + def test_exit_0_when_every_base_can_reach_master(self, mod, monkeypatch) -> None: + prs = [ + {"number": 1151, "baseRefName": "master", "headRefName": "h1"}, + {"number": 1152, "baseRefName": "feature/live", "headRefName": "h2"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/live": "s2"}, lambda sha: True) + assert mod.main(["--repo", "owner/repo"]) == 0 + + def test_exit_2_when_the_state_cannot_be_read(self, mod, monkeypatch) -> None: + """A check that reports OK when it could not look is worse than none.""" + def boom(repo): + raise RuntimeError("gh failed (rc=1): network down") + + monkeypatch.setattr(mod, "_open_prs", boom) + assert mod.main(["--repo", "owner/repo"]) == 2 + + def test_selecting_one_pr_filters_the_rest(self, mod, monkeypatch, capsys) -> None: + prs = [ + {"number": 1, "baseRefName": "feature/dead", "headRefName": "h1"}, + {"number": 2, "baseRefName": "master", "headRefName": "h2"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/dead": "s1"}, lambda sha: False) + rc = mod.main(["--repo", "owner/repo", "2"]) + out = capsys.readouterr().out + assert rc == 0, "PR 2 is fine; PR 1 was not asked about" + assert "#2" in out and "#1" not in out + + +class TestRealInvocationSurface: + """The script must be runnable as a tool, and must not silently pass on error.""" + + def test_help_exits_zero(self) -> None: + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--help"], + capture_output=True, + text=True, + ) + assert proc.returncode == 0 + assert "base branch" in proc.stdout + + def test_unreachable_gh_exits_2_not_0(self) -> None: + """With a bogus repo gh fails; the tool must not report a clean bill.""" + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", "argszero/definitely-not-a-repo-xyz"], + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin:/opt/homebrew/bin", "HOME": str(Path.home())}, + ) + assert proc.returncode == 2, (proc.returncode, proc.stdout, proc.stderr) From 3edd6c9c748a6bcbb95f6a1bd7655ceabbaa29f5 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 11 Sep 2026 21:47:29 +0800 Subject: [PATCH 2/5] emrg: a missing `gh` must not read as "a PR is on a dead end" CI caught this on the Windows runner (run 34605389233): `gh` is not installed there, so `subprocess.run(["gh", ...])` raised `FileNotFoundError` out of the test that drives the tool against a bogus repo. An uncaught exception exits **1** - which is this tool's code for "a PR is based on a dead end". A caller would then go hunting for a PR to retarget that does not exist, while the truth is that the state could not be read at all: the exact silent inversion the tool exists to prevent, in the tool itself. `_gh` now catches `FileNotFoundError` and raises the same `RuntimeError` as a failing `gh`, so both take the rc-2 "could not determine" path. `_branch_heads` is routed through `_gh` too, so it cannot bypass that guard with its own `subprocess.run`. The test is rewritten to pin the behaviour **regardless of which machine runs it**: it monkeypatches `subprocess.run` to raise `FileNotFoundError` rather than depending on `gh` being absent, which is what made the original test green-here-red-there. Verified against the real condition as well, with PATH pointed at an empty directory: rc 2 and "gh is not available on this machine ... cannot determine PR bases". Mutation-checked: removing the guard turns the suite red (3 failed, 13 errors); restored, 16 passed. Full suite 1498 passed, 1 skipped; `check-doc-count.py` OK. --- Agent.md | 2 +- scripts/check-pr-base.py | 50 +++++++++++++++++++++---------------- tests/test_check_pr_base.py | 24 ++++++++++++++++-- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/Agent.md b/Agent.md index 439abd15..9ac52b72 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1498) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1499) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) diff --git a/scripts/check-pr-base.py b/scripts/check-pr-base.py index bbda88bb..4c5c0bd2 100644 --- a/scripts/check-pr-base.py +++ b/scripts/check-pr-base.py @@ -65,14 +65,29 @@ def _gh(*args: str) -> str: - """Run `gh`, failing loud rather than guessing.""" - proc = subprocess.run( - ["gh", *args], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) + """Run `gh`, failing loud rather than guessing. + + A **missing** `gh` is a failure of the same kind as a failing one, and must + take the same exit path. It is not a hypothetical: on the Windows CI runner + `gh` is not installed, so the test that drives this tool against a bogus repo + raised `FileNotFoundError` out of `subprocess.run` - an uncaught exception, + which CPython reports as **rc 1**, i.e. "a PR is on a dead end". That is the + exact wrong answer this tool exists to prevent: a caller reading rc 1 would + go looking for a PR to retarget that does not exist, while the real state is + "could not look". Caught by CI (run 34605389233), not by review. + """ + try: + proc = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"gh is not available on this machine ({exc}); cannot determine PR bases" + ) from exc if proc.returncode != 0: raise RuntimeError( f"gh failed (rc={proc.returncode}): gh {' '.join(args)}\n" @@ -116,25 +131,16 @@ def _repo_path(repo: str | None) -> str: def _branch_heads(repo: str | None) -> dict[str, str]: """branch name -> head sha, for every branch on the remote.""" - proc = subprocess.run( - ["gh", "api", f"repos/{_repo_path(repo)}/branches", "--paginate"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if proc.returncode != 0: - raise RuntimeError( - f"gh failed (rc={proc.returncode}): listing branches\n" - f"{proc.stderr.strip()}" - ) + # `_gh` (not a bare subprocess.run) so a missing `gh` raises the same + # RuntimeError as a failing one and takes the rc-2 path. + out = _gh("api", f"repos/{_repo_path(repo)}/branches", "--paginate") # --paginate concatenates page arrays, which is not valid JSON as a whole; # the endpoint returns an array per page, so parse per document if it parses, # else fall back to line-delimited objects. try: - payload = json.loads(proc.stdout) + payload = json.loads(out) except json.JSONDecodeError: - payload = [json.loads(line) for line in proc.stdout.splitlines() if line.strip()] + payload = [json.loads(line) for line in out.splitlines() if line.strip()] if not isinstance(payload, list): raise RuntimeError("branch listing was not a list") heads = {} diff --git a/tests/test_check_pr_base.py b/tests/test_check_pr_base.py index d7fb5531..c56561cb 100644 --- a/tests/test_check_pr_base.py +++ b/tests/test_check_pr_base.py @@ -168,12 +168,32 @@ def test_help_exits_zero(self) -> None: assert proc.returncode == 0 assert "base branch" in proc.stdout - def test_unreachable_gh_exits_2_not_0(self) -> None: + def test_missing_gh_exits_2_not_1(self, monkeypatch) -> None: + """A missing `gh` must take the "could not look" path (rc 2), not rc 1. + + This is the failure CI caught on Windows (run 34605389233): `gh` is not + installed on that runner, `subprocess.run(["gh", ...])` raised + `FileNotFoundError`, and an uncaught exception exits **1** - which for + this tool means "a PR is based on a dead end". A caller would then hunt + for a PR to retarget that does not exist, while the truth is that the + state could not be read at all. Pinned here by simulating the missing + binary rather than depending on `gh` being absent on the test machine. + """ + mod = _load() + + def no_gh(*args, **kwargs): + raise FileNotFoundError(2, "No such file or directory", "gh") + + monkeypatch.setattr(mod.subprocess, "run", no_gh) + assert mod.main(["--repo", "argszero/emrg"]) == 2 + + def test_unreachable_repo_exits_2_not_0(self) -> None: """With a bogus repo gh fails; the tool must not report a clean bill.""" proc = subprocess.run( [sys.executable, str(SCRIPT), "--repo", "argszero/definitely-not-a-repo-xyz"], capture_output=True, text=True, - env={"PATH": "/usr/bin:/bin:/opt/homebrew/bin", "HOME": str(Path.home())}, ) + # On a machine with no `gh` at all (e.g. the Windows runner) this is 2 as + # well - the code path is now the same, which is the point. assert proc.returncode == 2, (proc.returncode, proc.stdout, proc.stderr) From 4cbe4ad6b08f854b44c42a9c3bdf7c9c473d7d7a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 11 Sep 2026 22:08:27 +0800 Subject: [PATCH 3/5] emrg: distinguish an in-flight stacked parent from a dead end `check-pr-base.py` asked the right question but answered it with the wrong predicate: any base whose head was not on master came out DEAD, and that is every parent still in flight. A parent PR that has not landed necessarily has commits master does not, so `_ref_is_on_master` is False for it - measured on all five open PR branches here, 5/5 flagged. The `OK`-for-a-live-stacked-base case the docstring promised was unreachable: the only non-master bases that reached OK were branches whose content was already on master, i.e. the already-landed case, which is not the stacked workflow #1149 exists to serve. The compare status cannot separate the two. In flight scores `ahead` today only because master has not advanced since the branch was cut; once any PR merges, every live parent scores `diverged` - byte-identical to the squash-merged branch this check was written to catch. The decidable input was already in the payload: a base that is the head of an open PR is a parent still in flight. Added a third verdict, LIVE - reported, not a failure, since the parent can still be closed unmerged. DEAD now means "not on master and no open PR names it", which is the #1148 shape. Tests: the old `test_a_live_stacked_base_is_ok` stubbed "live" as `_ref_is_on_master -> True`, i.e. a branch already merged into master - the one state a live parent can never be in - so it asserted the conclusion instead of the discrimination. Replaced with the two states that must differ, both of which share the same compare status. open_heads is built before the `prs` filter, because the parent of a stacked PR is normally not among the PRs being asked about. Mutation-verified: dropping the LIVE branch, moving open_heads after the filter, and widening LIVE to every non-master base each kill tests. Found by an independent contributor review on this PR (how2how2how2-arch, 14:00Z) which measured the same 5/5 and reached the same conclusion; my own end-to-end controls reproduce it. --- Agent.md | 4 +- scripts/check-pr-base.py | 72 +++++++++++++++++++++++++--------- tests/test_check_pr_base.py | 77 +++++++++++++++++++++++++++++++++---- 3 files changed, 126 insertions(+), 27 deletions(-) diff --git a/Agent.md b/Agent.md index 9ac52b72..16e3214c 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1499) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1503) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) @@ -128,7 +128,7 @@ Release bump: `python3 scripts/bump-version.py ` — 一次改齐 8 处 Doc count sync: `uv run --no-sync python3 scripts/check-doc-count.py [--write|--dry-run|--resolve-conflict]` — 测量当前树的 pytest 收集数并校验 Agent.md 的 Python 测试数(宿主侧自检,与 CI 的 test_python_count_matches_docs 对称);`--write` 仅单 token 替换那个数字(前后遮罩对比,其余字节不变),锚点缺失/出现多次/收集失败均 fail-loud(exit 2)。`--resolve-conflict` 专治合并冲突态:冲突块的**两侧按构造都是陈旧的**,`--write` 会(正确地)拒绝猜哪一侧为真;该模式去掉冲突标记后**在合并后的树上重新测量**写入,绝不选边——实测两侧写 1335/1337 而合并树实收 1339。冲突出现在计数行以外的位置即拒绝(不是什么通用「删标记」按钮)。用途:合并冲突解决后一条命令同步(#1119/#1120/#1121/#1122 四次冲突均在这一行;2026-09-11 #1130 落地后三个 PR 转 DIRTY,逐个解冲突都靠这条) Node count sync: `uv run --no-sync python3 scripts/check-node-test-count.py [--write|--dry-run]` — 直接问**真实运行器**(vitest / node --test)并校验 Agent.md 的 Renderer 与 GUI 两个总数:`tests/test_doc_counts.py` 只能**静态**数 `it(`/`test(` 定义(pytest 作业没有 node_modules),而静态计数只是运行器的**模型**——R2254(445→448)、#1120(同 stem 文件整份被吞)、#1125(`it.each`/`test.skip` 正则看不见)三次都是模型与实践脱节。GUI 侧按 CI 环境(EMRG_SKIP_INTEGRATION=1)运行后减去 1 个模块级 `skip()` 原因条目(该条目数会先断言为 1,形状变了就停手而不是报个看着像对的数)。缺 node_modules 即报该原因,绝不报假数 Vote count: `uv run --no-sync python3 scripts/check-vote-count.py ...` — 合并前数**仍然算数**的 LGTM 票,而不是评论里看得见的 ✅ 行数。三条规则让手工计数每次都错:① **票早于 head push 即作废**(rebase/解冲突推新 head 后,旧 ✅ 说的是已经不存在的 commit,而评论历史照样显示五六行 ✅,PR 看着能合实则零票——2026-09-11 实测 #1133/#1134/#1136/#1137 各显示 4-6 张 LGTM,解锁后**有效票全为 0**);② **❌ 重置计数**(三次 ✅ 后一个 needs-fix,再一个 ✅ 只算一票);③ **同一周期只算一票**(否则一个周期独自把 PR 送过门槛)。判票读正文首行**首个内容字符**(`gh pr review --comment` 让 GitHub 把 ✅ 和 ❌ 都记成 `COMMENTED`,state 字段在这里毫无用处);该字符**透过 markdown 装饰**读取(`**❌`/`- ❌`/`> ❌`/`## ❌`/`1. ❌` 都算,装饰字符本身不可能是判决),并**由它决定整行**——✅ 开头即使后文提到 ❌ 仍是批准(那些提及是在说「没有 ❌」,说法是开放集合),整行扫描只作为无开头标记时的兜底(散文式「Not LGTM」为否决、「Result: ❌ needs fix」为否决)。周期号从正文里取(`cyc20260911-091230`)。push 时间取**该 SHA 上最早一次 workflow run 的创建时间**(就是 GitHub 收到 push 事件的时刻);没有 run 时退回 commit 日期并**在输出里注明**(commit 日期可能早于 push,是乐观方向,不许默默采信)。exit 0 每张 PR 都够票 / 1 有 PR 缺票 / 2 问不出来(gh 失败、响应不可解析)——问不出来的问题绝不报数。评论**逐页取全**(reviews 端点默认只给 30 条且**按时间正序**,超限时丢掉的正是**最新的**票,也就是唯一算数的那几张;#1134 解锁一次就有 7 条评论),并在本地按时间排序(「❌ 重置计数」和「同周期只算一票」都是**位置相关**的规则,依赖服务端返回顺序等于把判决权外包)。两个自暴露的坑:① gh 的 `--jq` **后者覆盖前者**,helper 若自己追加 `--jq` 会吃掉调用方投影,字段名退回原名、`at` 读成空串,而 `"" <= push_time` 恒真 ⇒ **全部票作废**、有 2 票的 PR 显示 0/3(计数方向偏「安全」,没人会去查);故 helper 只管 `--paginate`,投影归调用方,并对载荷形状**运行时断言**(缺 `at` 即 exit 2,不报数) -PR base check: `uv run --no-sync python3 scripts/check-pr-base.py [--repo owner/name] [PR...]` — 合并前查每张 open PR 的 **base 分支是否还能到达 master**。`gh pr merge` 合的是 PR 的 **base 分支**,而 stacked PR(base 是另一条 feature 分支)的 base 在本仓库被 squash 合并后**永远不是 master 的祖先**:合进去只会落到那条死分支上,master 一无所获,而 PR 状态显示 MERGED、CI 双绿、票数也照常累计——三个现有闸门全都放行。实测(cyc20260911-210746):#1148 的 base 是 #1147 的分支,而 #1147 已 squash 合入 master,若照原样批准,这个「让 master 的分类器不再重复内容」的修复就会合进死分支,队列里留一张 MERGED 却什么都没改变的 PR。判据不是「base 是不是 master」(stacked PR 在父分支未合并时是正常且有用的工作流,正是 #1149 要服务的情形),而是**「合进这个 base 还能不能到 master」**:base 已在 master 上 ⇒ OK;base 已删除或其 head 不在 master ⇒ DEAD(须先 retarget);读法用 compare API 的 status(`identical`/`behind` = 在 master 上,`ahead`/`diverged` = 不在),四个状态都有测试钉住(把判据放宽成「不是 ahead 就算 OK」会让 `diverged` 蒙混过关)。exit 0 全部可达 / 1 有 PR 停在死路 / 2 问不出来(gh 失败、响应不可解析)——问不出来绝不报 OK。retarget 命令:`gh api -X PATCH repos///pulls/ -f base=master`(`gh pr edit --base` 会因 Projects-classic 弃用报错)。 +PR base check: `uv run --no-sync python3 scripts/check-pr-base.py [--repo owner/name] [PR...]` — 合并前查每张 open PR 的 **base 分支是否还能到达 master**。`gh pr merge` 合的是 PR 的 **base 分支**,而 stacked PR(base 是另一条 feature 分支)的 base 在本仓库被 squash 合并后**永远不是 master 的祖先**:合进去只会落到那条死分支上,master 一无所获,而 PR 状态显示 MERGED、CI 双绿、票数也照常累计——三个现有闸门全都放行。实测(cyc20260911-210746):#1148 的 base 是 #1147 的分支,而 #1147 已 squash 合入 master,若照原样批准,这个「让 master 的分类器不再重复内容」的修复就会合进死分支,队列里留一张 MERGED 却什么都没改变的 PR。判据不是「base 是不是 master」(stacked PR 在父分支未合并时是正常且有用的工作流,正是 #1149 要服务的情形),而是**「合进这个 base 还能不能到 master」**:base 已在 master 上 ⇒ OK;base 是**某张 open PR 的 head 分支** ⇒ LIVE(父分支仍在飞行中,合进去会落到活着的父分支上、随父 PR 落地;不是死路,但也不是保证——父 PR 仍可能被**未合并关闭**,故不判失败、只提示优先 retarget 到 master);base 既不在 master 上、也没有任何 open PR 以它为 head ⇒ DEAD(须先 retarget)。**LIVE 与 DEAD 无法由 compare status 区分**(cyc20260911-215935 实测修正:飞行中的父分支**必然**有 master 没有的 commit,status 就是 `ahead`;master 因任何无关原因前进后它又变成 `diverged`,与 squash 合并后的死分支**逐字节相同**——五个 open PR 分支当时全部是 `ahead`,只因 master 尚未前进)。可判定的输入是「有没有 open PR 的 head 等于这条分支」,而 `headRefName` 本就在抓取的字段里;旧版直接用 status 判 OK/DEAD,于是**每个飞行中的 stacked 父分支都被报成 DEAD**(5/5 真实父分支),而它自己文档里的「live stacked base 返回 OK」在旧谓词下**不可达**(能到 OK 的非 master base 只有「内容已在 master 上」的已落地分支,那不是 stacked 工作流)。LIVE/DEAD 两侧各有变异验证测试(删掉 LIVE 分支、或把 `open_heads` 挪到 `prs` 过滤之后,测试即变红——后者是过滤顺序陷阱:父 PR 通常不在被问的 PR 列表里)。status 读法用 compare API(`identical`/`behind` = 在 master 上,`ahead`/`diverged` = 不在),四个状态都有测试钉住(把判据放宽成「不是 ahead 就算 OK」会让 `diverged` 蒙混过关)。exit 0 无死路 / 1 有 PR 停在死路 / 2 问不出来(gh 失败、响应不可解析)——问不出来绝不报 OK。retarget 命令:`gh api -X PATCH repos///pulls/ -f base=master`(`gh pr edit --base` 会因 Projects-classic 弃用报错)。 Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) Git-over-https push 兜底: `python scripts/push-branch-from-api.py --branch feature/x [--ref HEAD] [--force]` — 同一宕机场景下的 push 方向(#988 配对):从本地 ref 沿一父链找到远端基点(已有分支头或首个远端已知祖先),自底向上上传 blobs(原始字节)/trees(`git mktree` 语义复算)/commits(结构化创建,author/committer 携带原始 +0800 偏移、消息去尾随换行——GitHub 规范化行为),更新远端 ref 后把本地分支 ref 重写为远端 sha 并 `git diff` 验证内容一致;失败即止不触碰 refs(hermetic 测试经忠实假 API 验证字节级 sha 一致) Conflict triage: `python3 scripts/classify-conflict.py ... [--all]` — 合并冲突的**分类器(只报告,绝不改文件,exit 1 = 有需要人判的块)**,把「两边都动了同一个文件」拆成五种可判定的形状并给出证据支持的解法:`count-line`(两侧只差一个**带括号的计数**数字 ⇒ 必须在合并后的树上**测量**,永不选边,交给 `check-doc-count.py --resolve-conflict`)、`duplicate`(两侧**声明符号**互为真超集**且共有符号的正文全同** ⇒ 取超集那侧)、`disjoint`(声明符号不相交 ⇒ **两边都保留**)、`overlapping`(同名符号,或超集但共有符号正文不同,或单行无判据 ⇒ 必须人读)、`identical`。存在理由(cyc20260911-112155 实测):同一轮里同一形状需要**相反**的解法——#1136 的分支带着 #1134 的**未合并副本**(分支基于 #1134 而后者已被 squash,祖先关系不可见),master 是严格超集故「取 theirs」正确;#1140 两侧是**不相交新增**,取任何一侧都会静默丢掉另一侧 4 个探针,唯一正解「两边都留」。一条 `--theirs` 对前者对、对后者错。两条判别轴都是被实测打出来的:① 必须是**声明符号**而非文本行——初版按内容行比较,因两侧都含 ` """` 与 ` )` 这种样板行,把两个真实冲突全判成 `overlapping`;② 名字子集**不等于**内容包含——只比名字时「master 声明了分支所有名字」被读成「master 含分支全部内容」,而两侧同名 `test_alpha` 正文不同时取超集会**静默丢掉分支对它的修改**(正是本工具要防的数据丢失);③ `count-line` 必须限定为**带括号的计数**,否则 `x = compute(1)` vs `compute(2)` 这种纯代码差异也会被答「测量、永不选边」并 exit 0,把唯一该人看的情况关掉。三条均已钉为变异验证的回归测试(cyc20260911-120717)。**第四类:读不了的布局即拒绝**(2026-09-11 实测)——`merge.conflictStyle = diff3` 会在 ours 与分隔符之间插入 `||||||| ` 段,而 `CONFLICT_BLOCK` **照样匹配**(把 base 段吞进 ours),于是判出来的两侧是(ours+base)vs theirs:一段 `disjoint` 被判成 `KEEP BOTH` 并 exit 0,而「都保留」恰好会把**谁都不想要的 base 副本**拼回去;本仓库的真实形态(Agent.md 计数行)更隐蔽——判成 `duplicate - ours is a strict superset - take OURS`,因为 base 段(带着**陈旧**计数的第三份拷贝)被算成 ours 多出来的行。故先于解析检查该标记并报 `unparsed-layout` + exit 1,与 `check-doc-count.py` 对同一布局的具名拒绝一致(那条路是 read-only 无损失,只会不必要地拦住一个本可解的 PR;这条路的错误建议会让解冲突的人**删掉自己的改动**,代价不对称)。**第五类:跨行聚合与「同一段落的两个修订」**(cyc20260911-190629 实测,两个缺陷都在**本工具自己**里,且都靠「拿全部真实冲突块做差分」而非手写 fixture 才暴露出来——手写 fixture 只会覆盖我本来就相信的形状):① 一条 `count-line` 原先是「两侧各一行」,但 51 次改 Agent.md 的提交里有 **3 次一次动掉 2 个以上计数**,git 会把它们并成**一个**块,该块因此落到内容行兜底、被判「两侧无共同行 ⇒ KEEP BOTH(拼接)」并 exit 0——拼接恰好把每个计数行**各留两份**,正是本仓库 `_duplicated_count_line_kinds` 守卫要拒的状态;现改为**对齐的、逐对只差数字的**块一律 `count-line`(任意长度,且每对都要满足带括号计数)。② 「两侧无共同行」**不等于**「各自新增」——同一段落的新旧两个修订永远不相等。当前 #1140 的 Agent.md 真实块里,ours 的两行段落正是 master 两行的**严格前缀**(890 vs 539、601 vs 471 字符),兜底判 KEEP BOTH 会同时留下陈旧与当前两份段落;现改为见到严格前缀关系即报 `overlapping` 交人读(前缀证据弱于符号子集,误差不对称:升级只是多读一次,「取 theirs」会静默丢一行)。两个修复各配变异验证(还原旧行为即测试变红)。 diff --git a/scripts/check-pr-base.py b/scripts/check-pr-base.py index 4c5c0bd2..d09495ef 100644 --- a/scripts/check-pr-base.py +++ b/scripts/check-pr-base.py @@ -32,20 +32,32 @@ A stacked PR is a legitimate, useful workflow *while the parent is in flight* - that is the whole point of #1149, which made CI run for non-master bases. The question is not "is the base master", it is **"can a merge into this base still -reach master"**, and that is decidable: - -* the base branch's head is **on** master (or is master) -> yes; the stacked PR is - fine and will arrive when the parent lands. `OK`. -* the base branch no longer exists, or its head is not on master -> **no**; the PR - is based on a dead end. `DEAD`. -* the parent PR was already merged -> nothing can carry the child over. `DEAD`. - -Distinguishing these is what keeps the check from being noise, since a stacked -PR on a live parent is normal and is the state #1149 exists to serve. +reach master"**: + +* the base branch's head is **on** master (or is master) -> yes; nothing to do. + `OK`. +* the base branch is the head of an **open PR** -> it is still in flight. A merge + into it goes into the live parent branch and rides along when the parent lands, + so this is not a dead end - but it is not a guarantee either, because the parent + can still be *closed* unmerged. Reported as `LIVE`, and **not** a failure. +* the base branch no longer exists, or its head is not on master and no open PR + is named by it -> **no**. This is the squash-merge shape: the parent PR landed + as a squash, so its branch was never an ancestor of master and merging into it + lands nothing. `DEAD`. + +The live case is what keeps the check from being noise, and it cannot be +recognised from the compare status - a parent that is in flight *necessarily* has +commits master does not, so its head scores `ahead`, or `diverged` as soon as +master moves for any unrelated reason. Both are byte-identical to a squash-merged +branch's status (measured 2026-09-11: all five open PR branches here scored +`ahead` only because master had not advanced since they were cut). The decidable +input is whether an open PR's head *is* that branch - which `_open_prs` already +fetches and this check previously ignored. Exit codes ---------- -0 every open PR's base can still reach master (or is master) +0 no open PR's base is a dead end (bases either reach master, or are an open + PR's head - the latter reported as LIVE) 1 at least one open PR is based on a dead end - retarget it before merging 2 the question could not be answered (gh failed, response unparseable) @@ -163,8 +175,14 @@ def _ref_is_on_master(sha: str, repo: str | None) -> bool: return status in ("identical", "behind") -def classify_base(base: str, branches: dict[str, str], repo: str | None) -> tuple[str, str]: - """`(verdict, why)` for one PR's base branch.""" +def classify_base( + base: str, branches: dict[str, str], repo: str | None, open_heads: set[str] = frozenset() +) -> tuple[str, str]: + """`(verdict, why)` for one PR's base branch. + + `open_heads` is the set of branches that are the head of an open PR; a base in + it is a parent still in flight (`LIVE`), which is not a dead end. + """ if base == MASTER: return ("OK", "base is master") if base not in branches: @@ -176,11 +194,21 @@ def classify_base(base: str, branches: dict[str, str], repo: str | None) -> tupl sha = branches[base] if _ref_is_on_master(sha, repo): return ("OK", f"base {base!r} ({sha[:8]}) is already on master") + if base in open_heads: + return ( + "LIVE", + f"base {base!r} ({sha[:8]}) is the head of an open PR, so this is a " + "stacked PR on a parent still in flight: a merge into it lands on the " + "live parent branch and arrives when the parent lands. Not a dead end " + "- but not a guarantee either, since the parent can still be closed " + "unmerged. Prefer retargeting to master before merging", + ) return ( "DEAD", - f"base {base!r} ({sha[:8]}) is not on master - it is a dead end. This is " - "the squash-merge shape: if the parent PR was squash-merged, the branch " - "was never an ancestor of master and merging into it lands nothing. " + f"base {base!r} ({sha[:8]}) is not on master and is not the head of any " + "open PR - it is a dead end. This is the squash-merge shape: if the parent " + "PR was squash-merged, the branch was never an ancestor of master and " + "merging into it lands nothing. " "Retarget this PR to master before approving it", ) @@ -202,6 +230,12 @@ def main(argv: list[str] | None = None) -> int: print(f"cannot determine PR bases: {exc}", file=sys.stderr) return 2 + # Branches that are the head of an open PR: a base among these is a parent + # still in flight (`LIVE`), not a dead end. Computed from **every** open PR, + # before any `prs` filter - the parent of a stacked PR is usually not one of + # the PRs being asked about, and is precisely the row we must not misjudge. + open_heads = {p.get("headRefName") for p in prs} - {None, ""} + if args.prs: wanted = set(args.prs) prs = [p for p in prs if p["number"] in wanted] @@ -209,11 +243,13 @@ def main(argv: list[str] | None = None) -> int: dead = 0 for pr in sorted(prs, key=lambda p: p["number"]): try: - verdict, why = classify_base(pr["baseRefName"], branches, args.repo) + verdict, why = classify_base( + pr["baseRefName"], branches, args.repo, open_heads + ) except (RuntimeError, json.JSONDecodeError) as exc: print(f"#{pr['number']} UNKNOWN base={pr['baseRefName']!r}: {exc}") return 2 - mark = "OK " if verdict == "OK" else "DEAD " + mark = f"{verdict:<5}" print(f"#{pr['number']} {mark} base={pr['baseRefName']!r} - {why}") if verdict == "DEAD": dead += 1 diff --git a/tests/test_check_pr_base.py b/tests/test_check_pr_base.py index c56561cb..ff2987a1 100644 --- a/tests/test_check_pr_base.py +++ b/tests/test_check_pr_base.py @@ -100,19 +100,58 @@ def test_a_base_not_on_master_is_dead(self, mod, monkeypatch) -> None: assert verdict == "DEAD" assert "not on master" in why - def test_a_live_stacked_base_is_ok(self, mod, monkeypatch) -> None: - """A stacked PR whose parent is still on master is legitimate, not a defect. - - This is the other half of the discrimination: if everything non-master were - flagged, the check would cry wolf on the normal stacked workflow. - """ + def test_a_base_already_on_master_is_ok(self, mod, monkeypatch) -> None: + """A base whose head is on master has nothing left to reach - `OK`.""" monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: True) verdict, why = mod.classify_base( - "feature/live-parent", {"feature/live-parent": "abc12345" * 5}, "owner/repo" + "feature/landed", {"feature/landed": "abc12345" * 5}, "owner/repo" ) assert verdict == "OK" assert "already on master" in why + def test_a_live_stacked_base_is_live_not_ok(self, mod, monkeypatch) -> None: + """The **in-flight** parent: the state a stacked PR is normally in. + + This is the discrimination the check exists for, and it is the one the + previous version of this test got wrong: it stubbed "live" as + `_ref_is_on_master -> True`, i.e. *a branch already merged into master* - + the one state a live parent can never be in. It therefore asserted the + conclusion (`OK`) instead of the discrimination, and would have kept + passing with the in-flight case dropped from the tool entirely. + + A parent that is in flight necessarily has commits master does not, so the + compare status is `ahead` (or `diverged`, once master moves for any + unrelated reason) - byte-identical to the squash-merged branch. The only + thing that separates them is whether an open PR's head *is* that branch. + """ + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: False) + verdict, why = mod.classify_base( + "feature/live-parent", + {"feature/live-parent": "abc12345" * 5}, + "owner/repo", + {"feature/live-parent"}, + ) + assert verdict == "LIVE", "an in-flight parent is not a dead end" + assert "still in flight" in why + + def test_a_stacked_base_with_no_open_parent_is_dead(self, mod, monkeypatch) -> None: + """Same branch, same compare status, no open PR naming it -> the #1148 shape. + + The two states above are only decidable *together*: the status is identical + (`_ref_is_on_master -> False` in both), so a tool that reads only the status + must return the same verdict for both. It does not - this is the half that + would catch a revert of the fix. + """ + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: False) + verdict, why = mod.classify_base( + "feature/dead-parent", + {"feature/dead-parent": "abc12345" * 5}, + "owner/repo", + {"feature/some-other-open-pr"}, + ) + assert verdict == "DEAD" + assert "not the head of any open PR" in why + class TestMainExitCodes: def _wire(self, mod, monkeypatch, prs, branches, on_master): @@ -136,6 +175,30 @@ def test_exit_0_when_every_base_can_reach_master(self, mod, monkeypatch) -> None self._wire(mod, monkeypatch, prs, {"feature/live": "s2"}, lambda sha: True) assert mod.main(["--repo", "owner/repo"]) == 0 + def test_exit_0_for_a_stacked_pr_on_an_open_parent(self, mod, monkeypatch) -> None: + """A stacked child of an open parent: reported, but not a failure. + + End-to-end (through `main`, not just `classify_base`) because the parent is + usually *not* among the PRs being asked about - the verdict depends on the + full open-PR list, so a filter applied before `open_heads` is built would + silently turn this back into `DEAD`. + """ + prs = [ + {"number": 1147, "baseRefName": "master", "headRefName": "feature/parent"}, + {"number": 1148, "baseRefName": "feature/parent", "headRefName": "feature/child"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/parent": "s1"}, lambda sha: False) + # Asking only about the child: the parent is in the payload but not selected. + assert mod.main(["--repo", "owner/repo", "1148"]) == 0 + + def test_exit_1_for_a_stacked_child_whose_parent_is_gone(self, mod, monkeypatch) -> None: + """The #1148 shape end-to-end: the parent branch exists, but no open PR names it.""" + prs = [ + {"number": 1148, "baseRefName": "feature/parent", "headRefName": "feature/child"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/parent": "s1"}, lambda sha: False) + assert mod.main(["--repo", "owner/repo", "1148"]) == 1 + def test_exit_2_when_the_state_cannot_be_read(self, mod, monkeypatch) -> None: """A check that reports OK when it could not look is worse than none.""" def boom(repo): From a12926f6edbfe52163bfb228b57d62a77ed363b3 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 13 Sep 2026 12:37:03 +0800 Subject: [PATCH 4/5] emrg: pin the encoding of the two text-mode subprocess calls in test_check_pr_base --- tests/test_check_pr_base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_check_pr_base.py b/tests/test_check_pr_base.py index ff2987a1..d55ac8b4 100644 --- a/tests/test_check_pr_base.py +++ b/tests/test_check_pr_base.py @@ -227,6 +227,7 @@ def test_help_exits_zero(self) -> None: [sys.executable, str(SCRIPT), "--help"], capture_output=True, text=True, + encoding="utf-8", ) assert proc.returncode == 0 assert "base branch" in proc.stdout @@ -256,6 +257,7 @@ def test_unreachable_repo_exits_2_not_0(self) -> None: [sys.executable, str(SCRIPT), "--repo", "argszero/definitely-not-a-repo-xyz"], capture_output=True, text=True, + encoding="utf-8", ) # On a machine with no `gh` at all (e.g. the Windows runner) this is 2 as # well - the code path is now the same, which is the point. From ae473b67c14829ef53bd839f854e5c37f0386005 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 13 Sep 2026 16:26:30 +0800 Subject: [PATCH 5/5] emrg: move the PR-base doc line off the anchor it shared with two other PRs --- Agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index 593134da..a5a1b44a 100644 --- a/Agent.md +++ b/Agent.md @@ -125,11 +125,11 @@ Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapsh CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Merge freshness: `uv run --no-sync python3 scripts/check-merge-freshness.py ...` — 合并前问一句「这条绿色 CI 说的还是**将要合并的那棵树**吗」。`pull_request` 事件下 GitHub 构建的是 `Merge into `(合到**分叉点**,不是当前 master):分叉点就是 master 时两者同一棵树,master 一移动就不是了,而 master 移动**不触发** `synchronize`(只有 push 分支才触发),于是绿灯永远保持绿灯却已经过期。#1137 实测:`MERGEABLE/CLEAN` + 双 job 全绿,两侧计数行都写成同一个 1397 ⇒ git **无冲突**自动合并、保留 1397,而合并树实收 1401,两个守卫在 master 上才变红——**干净合并才是危险的那一种**(冲突时人被迫看一眼,反而安全)。判定不用时间戳比较(时钟/秒级竞态会骗人)而用**图结构**:master 的 tip 是否是 head 的祖先(`compare/master...` 的 `identical`/`ahead`),是则 merge base 就是 master 本身、判决可平移。**祖先性只是一半**:head 含 master 但**根本没有 CI 运行**(push 事件丢失 ⇒ `no checks reported`)同样不算新鲜,故第二个条件是「该 SHA 上存在一个**通过**的运行」。按 SHA 而非分支取运行(分支推两次会有两个运行)。exit 0 全部新鲜 / 1 至少一条过期 / 2 问不出来(坏 PR、gh 失败、状态不认识)——不认识的状态一律 fail-loud,绝不默认新鲜 +PR base check: `uv run --no-sync python3 scripts/check-pr-base.py [--repo owner/name] [PR...]` — 合并前查每张 open PR 的 **base 分支是否还能到达 master**。`gh pr merge` 合的是 PR 的 **base 分支**,而 stacked PR(base 是另一条 feature 分支)的 base 在本仓库被 squash 合并后**永远不是 master 的祖先**:合进去只会落到那条死分支上,master 一无所获,而 PR 状态显示 MERGED、CI 双绿、票数也照常累计——三个现有闸门全都放行。实测(cyc20260911-210746):#1148 的 base 是 #1147 的分支,而 #1147 已 squash 合入 master,若照原样批准,这个「让 master 的分类器不再重复内容」的修复就会合进死分支,队列里留一张 MERGED 却什么都没改变的 PR。判据不是「base 是不是 master」(stacked PR 在父分支未合并时是正常且有用的工作流,正是 #1149 要服务的情形),而是**「合进这个 base 还能不能到 master」**:base 已在 master 上 ⇒ OK;base 是**某张 open PR 的 head 分支** ⇒ LIVE(父分支仍在飞行中,合进去会落到活着的父分支上、随父 PR 落地;不是死路,但也不是保证——父 PR 仍可能被**未合并关闭**,故不判失败、只提示优先 retarget 到 master);base 既不在 master 上、也没有任何 open PR 以它为 head ⇒ DEAD(须先 retarget)。**LIVE 与 DEAD 无法由 compare status 区分**(cyc20260911-215935 实测修正:飞行中的父分支**必然**有 master 没有的 commit,status 就是 `ahead`;master 因任何无关原因前进后它又变成 `diverged`,与 squash 合并后的死分支**逐字节相同**——五个 open PR 分支当时全部是 `ahead`,只因 master 尚未前进)。可判定的输入是「有没有 open PR 的 head 等于这条分支」,而 `headRefName` 本就在抓取的字段里;旧版直接用 status 判 OK/DEAD,于是**每个飞行中的 stacked 父分支都被报成 DEAD**(5/5 真实父分支),而它自己文档里的「live stacked base 返回 OK」在旧谓词下**不可达**(能到 OK 的非 master base 只有「内容已在 master 上」的已落地分支,那不是 stacked 工作流)。LIVE/DEAD 两侧各有变异验证测试(删掉 LIVE 分支、或把 `open_heads` 挪到 `prs` 过滤之后,测试即变红——后者是过滤顺序陷阱:父 PR 通常不在被问的 PR 列表里)。status 读法用 compare API(`identical`/`behind` = 在 master 上,`ahead`/`diverged` = 不在),四个状态都有测试钉住(把判据放宽成「不是 ahead 就算 OK」会让 `diverged` 蒙混过关)。exit 0 无死路 / 1 有 PR 停在死路 / 2 问不出来(gh 失败、响应不可解析)——问不出来绝不报 OK。retarget 命令:`gh api -X PATCH repos///pulls/ -f base=master`(`gh pr edit --base` 会因 Projects-classic 弃用报错)。 Release bump: `python3 scripts/bump-version.py ` — 一次改齐 8 处版本声明(`emrg/__init__.py`、`pyproject.toml`、`emrg/gui/package.json`、`emrg/gui/package-lock.json` 根 + `packages[""]`、`uv.lock`、`packaging/{build-runtime,make-installer,make-run-installer}.sh`);`--check` 只报告漂移(宿主侧自检,与 CI 的 test_version_sync 对称),`--dry-run` 预览不落盘。锚点缺失/数量不符即 fail-loud,绝不猜测;`uv.lock` 只改 `name = "emrg"` 那一行(v0.2.94 教训:直接 `uv run` 会把 lock 里所有 registry URL 重写成镜像,556 行环境噪声)。bump 后用 `uv run --no-sync pytest` 避免 uv 重生成 lock。**测的是「你站着的那个 checkout」**(与 doc/node count 两工具同一处修复:root 原先取自 `__file__` ⇒ 在 worktree 里跑主 checkout 的那份脚本会去**锁主树**——worktree 处于 9.9.9/7 处漂移时它报 `OK: all 8 version sources agree`,而 `bump` 会改写**另一个** checkout 的 8 处版本声明,含决定构建产物的那个)。现 root 从 cwd 解析,输出首行报 `tree: `。详见 Agent.md「Releasing」 Doc count sync: `uv run --no-sync python3 scripts/check-doc-count.py [--measure|--resolve-conflict]` — **「Python 测试数不写进任何 tracked 文件」的守卫**(宿主侧自检,与 CI 的 `tests/test_doc_counts.py::test_no_tracked_file_states_the_python_test_count` 同一份规则,两处读的是本工具里的同一个正则)。默认扫描全仓 tracked 文件(`tests/` 与 `scripts/` 除外——那两个树是规则与它的探针住的地方,必须能拼出「写死的样子」;该排除有实测背书:466 个 tracked 文件里这两处之外的命中只有下面那两条真实声明)并**指名文件+行号+写法**,exit 1。`--measure` 现场测量当前树收集的测试数(`pytest --collect-only`,缺 pytest/解析不出即 fail-loud,绝不报假数)。`--resolve-conflict` 专治计数行的合并冲突:**解法不是选边而是去掉那个数字**(两侧各自去掉计数后文本必须完全相同才动手;措辞也不同即拒绝——那是内容冲突,得人读)。**存在理由(2026-09-13 实测,宿主的决定)**:写死一行派生数字既是冲突磁铁又是静默错误源——14 张在飞 PR 里 11 张冲突,**11/11 冲突的都是这一行**(每张加测试的 PR 都得改同一个数字),而两个分支写**同一个值**时 git 干净合并、悄悄留下陈旧值(实测 #1179+#1180 都写 1601 而合并树实收 1603,守卫只在合并后的树上才红)。故数字不写、现场测量:`README.md`/`README.cn.md` 早就这么做了(改挂 Tests badge)。**测的是「你站着的那个 checkout」**(2026-09-11 实测的坑:解冲突要在 git worktree 里干活,而 root 原先取自 `__file__` ⇒ 跑主 checkout 的脚本、量的却是**主树**,worktree 自己的 Agent.md 写 1401 而工具报 `OK: ... documents 1420`——**读错了树还说一致**)。现在 root 从 cwd 解析(cwd 同时有 `Agent.md` 与 `scripts/` 才算 checkout),拿不准就退回脚本自身 root,并在输出首行**报出所测的树** Node count sync: `uv run --no-sync python3 scripts/check-node-test-count.py [--write|--dry-run]` — 直接问**真实运行器**(vitest / node --test)并校验 Agent.md 的 Renderer 与 GUI 两个总数:`tests/test_doc_counts.py` 只能**静态**数 `it(`/`test(` 定义(pytest 作业没有 node_modules),而静态计数只是运行器的**模型**——R2254(445→448)、#1120(同 stem 文件整份被吞)、#1125(`it.each`/`test.skip` 正则看不见)三次都是模型与实践脱节。GUI 侧按 CI 环境(EMRG_SKIP_INTEGRATION=1)运行后减去 1 个模块级 `skip()` 原因条目(该条目数会先断言为 1,形状变了就停手而不是报个看着像对的数)。缺 node_modules 即报该原因,绝不报假数。**同样测「你站着的那个 checkout」**(与 doc count 同一处修复:root 原先取自 `__file__`,在 worktree 里跑主 checkout 的脚本会量到主树,`--write` 会改错树;现从 cwd 解析并报出所测的树) Vote count: `uv run --no-sync python3 scripts/check-vote-count.py ...` — 合并前数**仍然算数**的 LGTM 票,而不是评论里看得见的 ✅ 行数。三条规则让手工计数每次都错:① **票早于 head push 即作废**(rebase/解冲突推新 head 后,旧 ✅ 说的是已经不存在的 commit,而评论历史照样显示五六行 ✅,PR 看着能合实则零票——2026-09-11 实测 #1133/#1134/#1136/#1137 各显示 4-6 张 LGTM,解锁后**有效票全为 0**);② **❌ 重置计数**(三次 ✅ 后一个 needs-fix,再一个 ✅ 只算一票);③ **同一周期只算一票**(否则一个周期独自把 PR 送过门槛)。判票读正文首行**首个内容字符**(`gh pr review --comment` 让 GitHub 把 ✅ 和 ❌ 都记成 `COMMENTED`,state 字段在这里毫无用处);该字符**透过 markdown 装饰**读取(`**❌`/`- ❌`/`> ❌`/`## ❌`/`1. ❌` 都算,装饰字符本身不可能是判决),并**由它决定整行**——✅ 开头即使后文提到 ❌ 仍是批准(那些提及是在说「没有 ❌」,说法是开放集合),整行扫描只作为无开头标记时的兜底(散文式「Not LGTM」为否决、「Result: ❌ needs fix」为否决);**首行完全不含判决时**(散文开头),继续在其后各行找**成句的**否决——标记必须**位于该行开头**,因此本仓库批准票里那种**描述**已解决否决的句子(「The earlier ❌ was resolved by pushing the fix myself」)仍算批准(2026-09-11 #1144 合并后由探针发现:此前只读首行 ⇒ 散文开头 + 下文否决被判为 `comment`,而 comment 被跳过 ⇒ run 不重置、陈旧批准照印 `READY 3/3`;381 条真实 body 分类零变化,该形态真实但当前未被使用)。周期号从正文里取(`cyc20260911-091230`)。push 时间取**该 SHA 上最早一次 workflow run 的创建时间**(就是 GitHub 收到 push 事件的时刻);没有 run 时退回 commit 日期并**在输出里注明**(commit 日期可能早于 push,是乐观方向,不许默默采信)。exit 0 每张 PR 都够票 / 1 有 PR 缺票 / 2 问不出来(gh 失败、响应不可解析)——问不出来的问题绝不报数。评论**逐页取全**(reviews 端点默认只给 30 条且**按时间正序**,超限时丢掉的正是**最新的**票,也就是唯一算数的那几张;#1134 解锁一次就有 7 条评论),并在本地按时间排序(「❌ 重置计数」和「同周期只算一票」都是**位置相关**的规则,依赖服务端返回顺序等于把判决权外包)。两个自暴露的坑:① gh 的 `--jq` **后者覆盖前者**,helper 若自己追加 `--jq` 会吃掉调用方投影,字段名退回原名、`at` 读成空串,而 `"" <= push_time` 恒真 ⇒ **全部票作废**、有 2 票的 PR 显示 0/3(计数方向偏「安全」,没人会去查);故 helper 只管 `--paginate`,投影归调用方,并对载荷形状**运行时断言**(缺 `at` 即 exit 2,不报数) -PR base check: `uv run --no-sync python3 scripts/check-pr-base.py [--repo owner/name] [PR...]` — 合并前查每张 open PR 的 **base 分支是否还能到达 master**。`gh pr merge` 合的是 PR 的 **base 分支**,而 stacked PR(base 是另一条 feature 分支)的 base 在本仓库被 squash 合并后**永远不是 master 的祖先**:合进去只会落到那条死分支上,master 一无所获,而 PR 状态显示 MERGED、CI 双绿、票数也照常累计——三个现有闸门全都放行。实测(cyc20260911-210746):#1148 的 base 是 #1147 的分支,而 #1147 已 squash 合入 master,若照原样批准,这个「让 master 的分类器不再重复内容」的修复就会合进死分支,队列里留一张 MERGED 却什么都没改变的 PR。判据不是「base 是不是 master」(stacked PR 在父分支未合并时是正常且有用的工作流,正是 #1149 要服务的情形),而是**「合进这个 base 还能不能到 master」**:base 已在 master 上 ⇒ OK;base 是**某张 open PR 的 head 分支** ⇒ LIVE(父分支仍在飞行中,合进去会落到活着的父分支上、随父 PR 落地;不是死路,但也不是保证——父 PR 仍可能被**未合并关闭**,故不判失败、只提示优先 retarget 到 master);base 既不在 master 上、也没有任何 open PR 以它为 head ⇒ DEAD(须先 retarget)。**LIVE 与 DEAD 无法由 compare status 区分**(cyc20260911-215935 实测修正:飞行中的父分支**必然**有 master 没有的 commit,status 就是 `ahead`;master 因任何无关原因前进后它又变成 `diverged`,与 squash 合并后的死分支**逐字节相同**——五个 open PR 分支当时全部是 `ahead`,只因 master 尚未前进)。可判定的输入是「有没有 open PR 的 head 等于这条分支」,而 `headRefName` 本就在抓取的字段里;旧版直接用 status 判 OK/DEAD,于是**每个飞行中的 stacked 父分支都被报成 DEAD**(5/5 真实父分支),而它自己文档里的「live stacked base 返回 OK」在旧谓词下**不可达**(能到 OK 的非 master base 只有「内容已在 master 上」的已落地分支,那不是 stacked 工作流)。LIVE/DEAD 两侧各有变异验证测试(删掉 LIVE 分支、或把 `open_heads` 挪到 `prs` 过滤之后,测试即变红——后者是过滤顺序陷阱:父 PR 通常不在被问的 PR 列表里)。status 读法用 compare API(`identical`/`behind` = 在 master 上,`ahead`/`diverged` = 不在),四个状态都有测试钉住(把判据放宽成「不是 ahead 就算 OK」会让 `diverged` 蒙混过关)。exit 0 无死路 / 1 有 PR 停在死路 / 2 问不出来(gh 失败、响应不可解析)——问不出来绝不报 OK。retarget 命令:`gh api -X PATCH repos///pulls/ -f base=master`(`gh pr edit --base` 会因 Projects-classic 弃用报错)。 Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) Git-over-https push 兜底: `python scripts/push-branch-from-api.py --branch feature/x [--ref HEAD] [--force]` — 同一宕机场景下的 push 方向(#988 配对):从本地 ref 沿一父链找到远端基点(已有分支头或首个远端已知祖先),自底向上上传 blobs(原始字节)/trees(`git mktree` 语义复算)/commits(结构化创建,author/committer 携带原始 +0800 偏移、消息去尾随换行——GitHub 规范化行为),更新远端 ref 后把本地分支 ref 重写为远端 sha 并 `git diff` 验证内容一致;失败即止不触碰 refs(hermetic 测试经忠实假 API 验证字节级 sha 一致) Merge pairs: `uv run --no-sync python3 scripts/check-merge-pairs.py [--base ] ...` — 合并前问「**任意两张** PR 能不能一起落地」:m 张 PR 有 m*(m-1) 个有序对,每对实测 master -> A -> B 并让守卫判 A+B 合并后的树。存在理由(cyc20260913-091152 实测 6 张可合并 PR 全 15 对、两个方向共 30 次):14 个有序对**冲突**(安全,git 拦下),**唯 1173↔1174 静默合并且落地坏树**(都写 `1564` ⇒ git 保留一份、零冲突,而合并树实收 `1566`)——两张各自 MERGEABLE/CLEAN、各自双绿、各自单测都 OK,**只有这一对不行**,而该工具族的默认计划(全部在飞 PR 升序)在第 1 步冲突即停、根本走不到这一对。顺序敏感:先落一张会推新 head 重测另一张,故每次落地前重跑。成本二次方(m 张 ≈ m*(m-1) 次守卫运行,master+A 每张只合一次),所以要传你真正在权衡的那几张。退出码:0 = 每个有序对都有答案且无「干净合并却落地坏树」的对(被冲突挡住的对**已作答**——它落不了地,也就不会落地落坏;这与 check-merge-sequence 的冲突=3 相反,那里链上后续步骤没测),1 = 存在这样的对,2 = 测不出来(响亮失败)。