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:
+115
-38
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user