diff --git a/.harness/config/checks.toml b/.harness/config/checks.toml index faf2aba..2435f44 100644 --- a/.harness/config/checks.toml +++ b/.harness/config/checks.toml @@ -111,3 +111,7 @@ requires = "macos" [reports] # 自动合并用的批准 App 的机器人账号(周报把它合并或批准的 PR 算作自动合并)。 bots = ["snowsonz-auto-merge"] + +[metrics.baseline] +label = "v0.8.0" +values = { "评审轮次" = 5, "声称已修但代码未变" = 3, "自述与事实不符" = 5, "临时探针 CI 运行" = 32, "首轮修复净增测试" = "16 项修复 / +1" } diff --git a/.harness/engine.lock b/.harness/engine.lock index f79029a..3f16493 100644 --- a/.harness/engine.lock +++ b/.harness/engine.lock @@ -1,6 +1,6 @@ { "engine": "delivery-harness", "version": "0.1.0", - "commit": "95d7f67b0d325af53b0bd0e6ec3ab5e13a2f90dc", - "tree": "sha256:10e1808e6f2aa91f3d86be1218a641154e3e42594b3c1feacf8080eeb03041f3" + "commit": "c22ddedd6c98c55e9a25025663bce72807ef81d3", + "tree": "sha256:a33b78ba880f14f5efdf3b158843291768e228f21764f50b6e5c85d0a642953d" } diff --git a/.harness/engine/checks/base_tests.py b/.harness/engine/checks/base_tests.py index 82bd818..7d400d7 100644 --- a/.harness/engine/checks/base_tests.py +++ b/.harness/engine/checks/base_tests.py @@ -48,6 +48,8 @@ def _restore_base(judged: str, base: str, worktree: Path, cwd: Path) -> None: def run(base: str, head: str = "HEAD", cwd: Path = ROOT) -> tuple[bool, bool, str]: """返回 (base 测试是否通过, 是否强制要求通过, 摘要)。测试框架被篡改时强制要求通过。""" + if not git("ls-tree", "-r", "--name-only", base, "--", "tests", cwd=cwd).strip(): + return True, False, "base 没有 tests/,无已有测试可回放" intended = [flag for flag in risk.classify(base, head, cwd).flags if flag.startswith(INTENDED)] temp = Path(tempfile.mkdtemp(prefix="base-tests-")) worktree = temp / "wt" diff --git a/.harness/engine/core/install.py b/.harness/engine/core/install.py index bc88ca1..c85f317 100644 --- a/.harness/engine/core/install.py +++ b/.harness/engine/core/install.py @@ -11,6 +11,8 @@ from __future__ import annotations import argparse +import json +import re import shutil import subprocess from pathlib import Path @@ -39,6 +41,35 @@ def engine_commit(allow_dirty: bool) -> str: return head.stdout.strip() + ("-dirty" if dirty else "") +def not_on_origin_main() -> str: + """引擎仓库当前提交不在本地 origin/main 上时返回原因(守卫的可信来源是合并到 main 的代码);否则空串。""" + repo = ENGINE_DIR.parent + if _git("rev-parse", "--verify", "-q", "origin/main", cwd=repo).returncode != 0: + return "引擎仓库没有 origin/main 引用,无法确认提交已合并" + if _git("merge-base", "--is-ancestor", "HEAD", "origin/main", cwd=repo).returncode != 0: + return "引擎仓库当前提交不在 origin/main 上:先 `git checkout main && git pull`" + return "" + + +def _version_tuple(text: str) -> tuple[int, ...]: + return tuple(int(part) for part in re.findall(r"\d+", text)[:3]) + + +def migration_notes(previous_version: str) -> list[str]: + """CHANGELOG 中晚于 previous_version 的版本段(含 Unreleased)里带 `**Migration:**` 的条目。previous_version 为空时不提示。""" + changelog = ENGINE_DIR.parent / "CHANGELOG.md" + if not previous_version or not changelog.exists(): + return [] + notes, applies = [], False + for line in changelog.read_text(encoding="utf-8").splitlines(): + if line.startswith("## "): + version = re.search(r"\d+\.\d+\.\d+", line) + applies = "Unreleased" in line or (version is not None and _version_tuple(version.group()) > _version_tuple(previous_version)) + elif applies and "**Migration:**" in line: + notes.append(line.lstrip("- ").strip()) + return notes + + def copy_engine(target: Path) -> Path: destination = target / ".harness" / "engine" if destination.resolve() == ENGINE_DIR.resolve(): @@ -70,6 +101,8 @@ def copy_templates(target: Path) -> tuple[list[str], list[str]]: def install(target: Path, templates: bool, allow_dirty: bool) -> int: if not (target / ".git").exists(): raise SystemExit(f"{target} 不是 git 仓库的根目录") + if not allow_dirty and (reason := not_on_origin_main()): + raise SystemExit(reason + "(开发调试可加 --allow-dirty)") lock = target / ".harness" / "engine.lock" previous = lock.read_text(encoding="utf-8") if lock.exists() else "" commit = engine_commit(allow_dirty) @@ -78,6 +111,12 @@ def install(target: Path, templates: bool, allow_dirty: bool) -> int: print(f"引擎 {__version__}({commit[:12]})已装到 {destination.relative_to(target)},锁文件已写入") if previous: print(" 升级前的锁文件:" + " ".join(previous.split())) + notes = migration_notes(json.loads(previous).get("version", "")) + if notes: + print("升级后需要你手工处理的迁移项(CHANGELOG):") + for note in notes: + print(f" ! {note}") + print("升级后:bin/verify --full,经 PR 合并(R3,由用户批准);流程见引擎仓库 docs/upgrading.md") if templates: written, skipped = copy_templates(target) for rel in written: @@ -94,7 +133,7 @@ def install(target: Path, templates: bool, allow_dirty: bool) -> int: def main(argv: list[str] | None = None, command: str = "install") -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--target", required=True, help="业务仓库根目录") - parser.add_argument("--allow-dirty", action="store_true", help="允许引擎仓库有未提交改动(仅限开发调试,锁文件会标 -dirty)") + parser.add_argument("--allow-dirty", action="store_true", help="允许引擎仓库有未提交改动、提交不在 origin/main 上(仅限开发调试,锁文件会标 -dirty)") args = parser.parse_args(argv) return install(Path(args.target).resolve(), templates=command == "install", allow_dirty=args.allow_dirty) diff --git a/.harness/engine/reports/metrics.py b/.harness/engine/reports/metrics.py index a627528..cf0e9a3 100644 --- a/.harness/engine/reports/metrics.py +++ b/.harness/engine/reports/metrics.py @@ -1,4 +1,4 @@ -"""交付度量:统计一次交付(base..head)中机器可数的指标,并与 v0.8.0 基线并列。 +"""交付度量:统计一次交付(base..head)中机器可数的指标,并与项目登记的基线(checks.toml [metrics.baseline],可选)并列。 bin/harness metrics --base origin/main [--head HEAD] [--github] [--json] @@ -18,17 +18,17 @@ from engine.checks import evidence from engine.core.cases import load_project_cases -from engine.core.common import ROOT, added_lines, git +from engine.core.common import ROOT, added_lines, git, setting from engine.routing import risk, run_check -# docs/review/2026-09-25-harness-baseline.md §2 -V080_BASELINE = { - "评审轮次": 5, - "声称已修但代码未变": 3, - "自述与事实不符": 5, - "临时探针 CI 运行": 32, - "首轮修复净增测试": "16 项修复 / +1", -} + +def baseline_line() -> str: + """checks.toml [metrics.baseline] 的 label 与 values 拼成一行;未登记则为空(不并列任何基线)。""" + values = setting("metrics.baseline", "values", {}) + if not values: + return "" + label = setting("metrics.baseline", "label", "基线") + return f"{label} 基线:" + ";".join(f"{key} {value}" for key, value in values.items()) + "。" def added_test_functions(base: str, head: str, cwd: Path = ROOT) -> int: @@ -104,7 +104,8 @@ def render(data: dict) -> str: continue shown = "、".join(value) if isinstance(value, list) else value lines.append(f"| {key} | {shown if shown != '' else '—'} |") - lines += ["", "v0.8.0 基线:" + ";".join(f"{key} {value}" for key, value in V080_BASELINE.items()) + "。"] + if baseline_line(): + lines += ["", baseline_line()] return "\n".join(lines) + "\n" diff --git a/.harness/engine/reports/weekly.py b/.harness/engine/reports/weekly.py index fff6e0d..6614150 100644 --- a/.harness/engine/reports/weekly.py +++ b/.harness/engine/reports/weekly.py @@ -333,8 +333,8 @@ def render(week: Week, data: dict, flagged: list[str], budgets: list[str], trial lines += ["### 误差预算", "", "| 类别 | 窗口 | 消耗 | 状态 |", "|---|---|---|---|"] + (budgets or ["| — | — | — | — |"]) lines += ["", "### 试跑记录(自动汇总)", "", "| 任务 | PR | 人工评审 | 要求修改 | 升级 | 逃逸 |", "|---|---|---|---|---|---|"] + (trials or ["| — | — | — | — | — | — |"]) - lines += ["", "v0.8.0 基线:" + ";".join(f"{key} {value}" for key, value in metrics.V080_BASELINE.items()) + "。", - "", "错误分析:由不是本周主要设计方的评审方按设计 11.1 做,结论存 `docs/review/weekly/<年-周>.md`。", ""] + lines += [""] + ([metrics.baseline_line(), ""] if metrics.baseline_line() else []) + lines += ["错误分析:由不是本周主要设计方的评审方按设计 11.1 做,结论存 `docs/review/weekly/<年-周>.md`。", ""] lines += [f""] return "\n".join(lines) + "\n" diff --git a/.harness/engine/routing/policy.py b/.harness/engine/routing/policy.py index d4fa74b..cc64e3c 100644 --- a/.harness/engine/routing/policy.py +++ b/.harness/engine/routing/policy.py @@ -30,7 +30,7 @@ from pathlib import Path from engine.checks import r1_checks, taskbook -from engine.core.common import ROOT, commit_field, git, path_matches, setting +from engine.core.common import ROOT, ConfigError, commit_field, git, path_matches, setting from engine.routing import risk, run_check CONTRACT_PATTERNS = ["docs/plans/task-*.md", "docs/templates/**", "docs/plans/backlog.md", "docs/specs/**"] @@ -41,6 +41,23 @@ def ui_patterns() -> list[str]: return list(setting("sources", "ui", [])) +APPROVAL_MODES = ("app", "none") + + +def platform_outputs() -> dict[str, str]: + """checks.toml [platform]:自动合并工作流的批准方式与 App 变量名。approval = "app"(默认,两个账号 + 批准 App) + 或 "none"(单账号:ruleset 不要求批准,工作流用 GITHUB_TOKEN 合并,风险见 SECURITY.md)。""" + approval = setting("platform", "approval", "app") + if approval not in APPROVAL_MODES: + raise ConfigError(f".harness/config/checks.toml 的 [platform] approval 只能是 {' 或 '.join(APPROVAL_MODES)},得到 {approval!r}") + return { + "approval": approval, + "app_client_id_var": setting("platform", "app_client_id_var", "HARNESS_APP_CLIENT_ID"), + "app_private_key_secret": setting("platform", "app_private_key_secret", "HARNESS_APP_PRIVATE_KEY"), + "environment": setting("platform", "environment", "harness-auto-merge"), + } + + BUDGET_LABEL = "budget-exceeded" ESCAPE_LABEL = "escape" @@ -249,6 +266,7 @@ def main(argv: list[str] | None = None) -> int: f"risk={facts.risk.label}\nclass={facts.machine_class}\n" f"auto_merge={'true' if auto else 'false'}\naudit={'true' if audit else 'false'}\n" ) + handle.writelines(f"{key}={value}\n" for key, value in platform_outputs().items()) return 0