fix(A5): 体积凭证指纹改取 git 索引 blob——跨 checkout 稳定,并拦未 staged 改动

现象(道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 不符。
This commit is contained in:
2026-10-25 04:18:32 +08:00
parent e1967cf2ea
commit 7c4628b043
2 changed files with 122 additions and 44 deletions
+115 -38
View File
@@ -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") # "<mode> <sha> <stage>"
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"<deleted>"
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)
+7 -6
View File
@@ -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"
}