From 7c4628b043f1869b0212ec9022c203728ae6c340 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 25 Oct 2026 04:18:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(A5):=20=E4=BD=93=E7=A7=AF=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E6=94=B9=E5=8F=96=20git=20=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=20blob=E2=80=94=E2=80=94=E8=B7=A8=20checkout=20=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=EF=BC=8C=E5=B9=B6=E6=8B=A6=E6=9C=AA=20staged=20?= =?UTF-8?q?=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象(道C-3 开工第一步就撞上,属于上一提交 e25e51c 引入的缺陷): _wt/P1-10-test-fw 里 check_size_gate --local 报"不同码",而两棵树 git status 全干净、 HEAD 同为 e1967cf。定位:主工作树的受管源码里 **1046/1388 个文件落成 CRLF**,新建 工作树同一提交落成 **LF**(实测 Drivers/BSP/dbg_cfg.h:1293 B/CRLF vs 1247 B/LF), 但两者的**索引 blob 完全相同**(都是 c39395f7…)。根因是本仓 core.autocrlf=true 与 .gitattributes 的 eol=lf 并存——所以"工作树原始字节"根本不是跨 checkout 的稳定身份。 后果比红一次更糟:主树盖的凭证在**每一条道的平行工作树**里都会误红,runner 上同样 (checkout 出来的行尾取决于那台机器的配置)→ 门禁自己造 false positive。 修法(AGENTS「行尾不依赖 core.autocrlf」的机器化版本): - 指纹 = git **索引** blob:`git ls-files -s` 的 path+blob 逐行聚合哈希(v2), 跨 checkout / 跨机器一致;凭证 schema 从 1 抬到 2,旧凭证直接判"不符、重新盖戳", 不做静默兼容(v1 凭证在别的树里必然误红,留着只会污染判读)。 - 新增第四道前置:**范围内有未 staged 改动/未跟踪文件时拒绝盖戳**——未 staged 的部分 不在索引里,凭证会声称覆盖一份并不存在的代码。已 git add 的不算脏:索引已含其内容, 正是"将要提交并被测量"的那一份。--local/--ci 同样把这类脏单独报出来。 - 新增守卫的机理性说明写在 index_snapshot() 上方与 docstring 的"指纹范围"节(唯一正文)。 - scripts/size_attestation.json:按 v2 重新盖戳(本树产品态实测 Code=214104 / RW=1204 / ZI=131852 / RW_IRAM1 75,632 B / RW_CCM 57,376 B,与主干 2026-10-25 台账行同源)。 验证: - 跨树一致性:_wt/P1-10-test-fw 与主工作树分别独立算索引指纹,结果都是 bf6ec94a761e…(1388 文件)→ CROSS-TREE STABLE。 - --stamp / --local / --ci 三态均 rc=0;凭证与 tsv 末行互证同源(Code=214,104 B)。 - 负向:在 test/board/ 放一个未跟踪探针文件 → --local 与 --stamp 双双 rc=1 并点名该 文件;删除后 --local 回到 rc=0(本树已确认干净,探针不留痕)。 - py_compile 通过;门禁实跑:no-mojibake / trailing-whitespace / end-of-file-fixer / check-added-large-files / id-check 全部 0。 RAM 增量:0 B(凭证与门禁脚本,不进固件)。gate:全过,未用 --no-verify。 ⚠ 需要 lead 做一步:合入后主树跑一次 python scripts/check_size_gate.py --stamp (或 ram_baseline.py --update)把 v1 凭证换成 v2,否则主树的 L0c 会报 schema 不符。 --- scripts/check_size_gate.py | 153 +++++++++++++++++++++++++--------- scripts/size_attestation.json | 13 +-- 2 files changed, 122 insertions(+), 44 deletions(-) diff --git a/scripts/check_size_gate.py b/scripts/check_size_gate.py index effed911..4f571fa9 100644 --- a/scripts/check_size_gate.py +++ b/scripts/check_size_gate.py @@ -47,8 +47,13 @@ 指纹范围(= 会影响固件体积的全部受管源码) App/ Drivers/ Src/ Inc/ Lib/ Middlewares/ test/board/ 以及 MDK-ARM 下的 .sct/.s -列表取 `git ls-files`(与 layer-deps / id-check 同口径),逐文件 sha256(路径+内容) 再 -聚合;未跟踪文件与构建产物天然不在内。 +身份取 **git 索引 blob**(`git ls-files -s` 的 path+blob 逐行聚合哈希),不是工作树 +原始字节——本仓 core.autocrlf=true 而 .gitattributes 写死 eol=lf,实测主树 1046/1388 +个受管源码落成 CRLF、新建工作树同一提交落成 LF,而索引 blob 完全一致;按原始字节算 +会让同一份代码在不同 checkout 得到不同指纹(主树盖的戳在别人工作树与 runner 上误红) +→ 索引 blob 才是跨 checkout 稳定的身份,也是 AGENTS「不依赖 core.autocrlf」的机器化。 +推论:**未 staged 的改动不在索引里**,凭证覆盖不到 → --stamp 拒绝在有脏时盖章, +--local/--ci 也把它单独报出来(已 git add 的不算脏:索引已含其内容,正是要测的那份)。 用法(仓库根): python scripts/check_size_gate.py --stamp @@ -76,7 +81,9 @@ import ram_report # noqa: E402 REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent ATTESTATION = REPO_ROOT / "scripts" / "size_attestation.json" -SCHEMA = 1 +# v1 = 工作树原始字节指纹(跨 checkout 不稳定,见 index_snapshot 的实测说明)→ 作废; +# v2 = git 索引 blob 指纹。schema 不符即拒绝复用,逼一次重新盖戳,不静默误判。 +SCHEMA = 2 SIZE_SCOPE_DIRS = ("App/", "Drivers/", "Src/", "Inc/", "Lib/", "Middlewares/", "test/board/") @@ -93,46 +100,89 @@ def in_scope(rel): return False -def tracked_files(): - """受管文件列表(git 不可用时退回目录遍历,同 check_layer_deps.py)。""" +def _git(args): + """跑 git 并返回 stdout 文本;git 不可用/非仓库 → 抛 RuntimeError(调用方按环境错误)。""" try: - out = subprocess.run(["git", "ls-files", "-z"], cwd=str(REPO_ROOT), + out = subprocess.run(["git"] + args, cwd=str(REPO_ROOT), capture_output=True, check=True) - except Exception: # noqa: BLE001 git 缺失/不在仓库内 - files = [] - for root, dirs, names in os.walk(REPO_ROOT): - dirs[:] = [d for d in dirs if d not in (".git", ".venv", "build")] - for n in names: - rel = os.path.relpath(os.path.join(root, n), REPO_ROOT) - files.append(rel.replace(os.sep, "/")) - return sorted(files) - return sorted(p.decode("utf-8", "replace") for p in out.stdout.split(b"\0") if p) + except subprocess.CalledProcessError as exc: + raise RuntimeError("git %s 失败:%s" % (" ".join(args), exc)) from exc + except (OSError, subprocess.SubprocessError) as exc: + raise RuntimeError("无法调用 git:%s" % exc) from exc + return out.stdout -def scoped_files(): - return [f for f in tracked_files() if in_scope(f)] +def index_snapshot(): + """受管且**在指纹范围内**的 (路径, 索引 blob id) 有序表。 + + ⚠ 为什么取**索引 blob**而不是工作树原始字节(2026-10-25 实测发现,别改回去): + 本仓 core.autocrlf=true,而 .gitattributes 写死 eol=lf —— 主工作树的受管源码 + 里有 **1046/1388 个文件落成 CRLF**,新建工作树同一提交落成 LF,两者**索引 blob + 完全相同**(实测 dbg_cfg.h:1247 B/LF vs 1293 B/CRLF,blob 都是 c39395f7)。 + 拿原始字节算指纹 = 同一份代码在不同 checkout 得到不同指纹:主树盖的戳在道C + 新工作树里判"不同码",runner 上同样会误红——**门禁自己造 false positive**。 + 索引 blob 是 git 归一化后的身份,跨 checkout 稳定,也正是 AGENTS「行尾由 + .gitattributes 写死、不依赖 core.autocrlf」的机器化说法。 + """ + raw = _git(["ls-files", "-s", "-z"]) + rows = [] + for item in raw.split(b"\0"): + if not item: + continue + meta, _, path = item.partition(b"\t") # " " + parts = meta.decode("ascii", "replace").split(" ") + if len(parts) < 2: + continue + rel = path.decode("utf-8", "surrogateescape").replace(os.sep, "/") + if in_scope(rel): + rows.append((rel, parts[1])) + return sorted(rows) def tree_fingerprint(): - """(指纹, 文件数, 缺失数)。受管但已删除的文件以哨兵入指纹,保证增删也变指纹。""" + """(指纹, 文件数)。指纹 = sha256(每行 "path\\tblob" 拼接),取自 git 索引。""" + rows = index_snapshot() h = hashlib.sha256() - n = miss = 0 - for rel in scoped_files(): - n += 1 - try: - blob = (REPO_ROOT / rel).read_bytes() - except OSError: - miss += 1 - blob = b"" + for rel, blob in rows: h.update(rel.encode("utf-8", "surrogateescape")) - h.update(b"\0") - h.update(hashlib.sha256(blob).digest()) - return h.hexdigest(), n, miss + h.update(b"\t") + h.update(blob.encode("ascii", "replace")) + h.update(b"\n") + return h.hexdigest(), len(rows) + + +def unmeasured_dirt(limit=8): + """范围内**未 staged** 的改动(含未跟踪文件)——它们不在索引里,指纹覆盖不到。 + + 返回 (条数, 样例列表)。判据用 porcelain v1 的第二列(工作树列):非空格且非 + 未跟踪标记即"改了没 staged";'??' = 未跟踪新文件。⚠ 第一列(索引列)有改动是 + **允许**的——那正是"已 git add、即将提交"的那份内容,索引已经把它算进指纹。 + """ + raw = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + dirty = [] + for rec in raw.split(b"\0"): + if len(rec) < 4: + continue + xy = rec[:2].decode("ascii", "replace") + rel = rec[3:].decode("utf-8", "surrogateescape").replace(os.sep, "/") + if not in_scope(rel): + continue + if xy == "??": + dirty.append(rel) + elif xy[1] != " ": + dirty.append(rel) + return len(dirty), dirty[:limit] def newest_source_mtime(): + """范围内**工作树文件**的最新 mtime(只用于 .map 新鲜度守卫,不进指纹)。 + + 注意这里必须看工作树:守卫要抓的是"盖戳前又改了文件却没重编",而那正是索引 + 之外(未 staged)或已 staged 但磁盘更新的那份内容。文件已删除则跳过(增删本身 + 由指纹变化表达)。 + """ newest = 0.0 - for rel in scoped_files(): + for rel, _blob in index_snapshot(): try: newest = max(newest, (REPO_ROOT / rel).stat().st_mtime) except OSError: @@ -227,12 +277,28 @@ def cmd_stamp(args): for b in bad: print(" - " + b, file=sys.stderr) return 1 - fp, n, miss = tree_fingerprint() + # 第四道前置:范围内**有未 staged 的改动就拒绝盖戳**——索引(= 即将提交的那份) + # 里没有它们,凭证会"声称覆盖"一份并不存在的代码。已 git add 的改动**不算脏** + # (索引已含其内容,正是凭证该描述的东西)。 + dirt, samples = unmeasured_dirt() + if dirt: + print("[FAIL] 指纹范围内有 %d 处未 staged 的改动/未跟踪文件,拒绝盖戳:" % dirt, + file=sys.stderr) + for s in samples: + print(" - " + s, file=sys.stderr) + print(" 原因:凭证按 git 索引内容生成,未 staged 的部分不在索引里、覆盖不到。", + file=sys.stderr) + print(" 修复:先 git add <这些文件>(凭证描述的就是你正要提交的那份),再盖戳;", + file=sys.stderr) + print(" 或把它们还原。@build 与盖戳都应在同一份内容上做。") + return 1 + fp, n = tree_fingerprint() write_attestation({ "schema": SCHEMA, "fingerprint": fp, + "fingerprint_source": "git-index", "fingerprint_files": n, - "fingerprint_missing": miss, + "worktree_dirt_at_stamp": dirt, "sizes": m["sizes"], "regions": m["regions"], "pct": m["pct"], @@ -248,7 +314,7 @@ def cmd_stamp(args): }) ir = m["regions"].get("RW_IRAM1", {}).get("size", 0) cc = m["regions"].get("RW_CCM", {}).get("size", 0) - print("[OK] 体积同码凭证已写出:%s(指纹 %s…,%d 个文件)" % ( + print("[OK] 体积同码凭证已写出:%s(索引指纹 %s…,%d 个文件)" % ( ATTESTATION.relative_to(REPO_ROOT), fp[:12], n)) print(" Code=%s B / RW_IRAM1=%s B / RW_CCM=%s B" % ( format(m["sizes"]["code"], ","), format(ir, ","), format(cc, ","))) @@ -276,17 +342,25 @@ def _cmd_parity(args, ci_mode): " 修复:本地 @build 之后跑 python scripts/ram_baseline.py --update", " (或单独跑 python scripts/check_size_gate.py --stamp)", ]) - fp, n, miss = tree_fingerprint() + fp, n = tree_fingerprint() problems = [] + dirt, samples = unmeasured_dirt() + if dirt: + problems += [ + "指纹范围内有 %d 处**未 staged** 的改动/未跟踪文件,凭证覆盖不到它们" % dirt, + " " + "、".join(samples) + ("…" if dirt > len(samples) else ""), + " 含义:这些内容既没进索引(凭证按索引生成),也就没被那次测量算进去", + " 修复:git add 之后 @build,再跑 python scripts/ram_baseline.py --update", + ] if fp != doc["fingerprint"]: problems += [ "本次代码(指纹 %s…,%d 文件)与体积测量(指纹 %s…,%s)不同码" % ( fp[:12], n, str(doc["fingerprint"])[:12], doc.get("built_at", "?")), " 含义:这份源码没有被真实链接测量过(或测量完又改了代码/合入了别的改动)", " 修复:@build 0/0 之后跑 python scripts/ram_baseline.py --update 再提交", + " (指纹取 git 索引 blob:同一份内容在任何工作树/runner 上算法一致," + "不受 CRLF/LF 检出差异影响)", ] - if miss: - problems.append(" (注:当前树缺 %d 个受管文件,可能 checkout 不完整)" % miss) if ci_mode: thr = doc.get("threshold_pct", ram_baseline.THRESHOLD_PCT) bad = [] @@ -338,8 +412,11 @@ def _tsv_consistency(doc): def cmd_show(_args): - fp, n, miss = tree_fingerprint() - print("当前受管源码指纹:%s(%d 文件,缺失 %d)" % (fp, n, miss)) + fp, n = tree_fingerprint() + dirt, samples = unmeasured_dirt() + print("当前受管源码索引指纹:%s(%d 文件;未 staged %d 处)" % (fp, n, dirt)) + for s in samples: + print(" dirty: " + s) doc, err = read_attestation() if doc is None: print("凭证:%s" % err) diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json index 4ff545e0..451126e3 100644 --- a/scripts/size_attestation.json +++ b/scripts/size_attestation.json @@ -1,15 +1,15 @@ { - "built_at": "2026-10-25T03:58:34+08:00", - "fingerprint": "b18aee6e56c7388f103939646190742b22bb6573f6667202e4ae9f8213485579", + "built_at": "2026-10-25T04:15:33+08:00", + "fingerprint": "bf6ec94a761eacd8e3bcbc590e8780c2293462d384b9b33094e5d0fd6411f295", "fingerprint_files": 1388, - "fingerprint_missing": 0, + "fingerprint_source": "git-index", "gated_regions": [ "RW_IRAM1", "RW_CCM" ], - "git_head": "ba9450c50b932bd2085809fb3c1acc8d72bafbb9", + "git_head": "e1967cf2eac70b794c920774715c6fe78dc30ac2", "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", - "map_mtime": 1792870197, + "map_mtime": 1792872260, "pct": { "ER_IROM1": 22.36, "RW_CCM": 87.89, @@ -34,7 +34,7 @@ "size": 48 } }, - "schema": 1, + "schema": 2, "scope_note": "\u540c\u7801\u51ed\u8bc1\uff1a\u6307\u7eb9\u8986\u76d6\u56fa\u4ef6\u4f53\u79ef\u76f8\u5173\u6e90\u7801\uff0c\u53e3\u5f84\u89c1\u672c\u811a\u672c docstring", "sizes": { "code": 214104, @@ -43,5 +43,6 @@ "zi_data": 131852 }, "threshold_pct": 92.0, + "worktree_dirt_at_stamp": 0, "written_by": "scripts/check_size_gate.py --stamp" }