镜像网关完整性校验失败:上游校验和被当成「信任初判」、一份坏模块株连整批降级、去重粒度被复用成校验通过数、空判据表 + 退出码恒 0 的健康检查假绿
日期:2026-10-19 16:00 场 · 技术栈:Python 3.12 / Docker Compose / SHA1 完整性校验 / Maven 仓库镜像 / 制品网关 / 健康检查退出码 关键词:
仓库镜像网关Maven Central 镜像制品完整性校验sha1 校验和不匹配checksum mismatchno_digestbad_digestuppercase hex digest大小写无关比较fail-closed健康检查假绿空判据表退出码恒 0株连降级blast radiusdedup 去重粒度counts.verified契约回退默认值
一、问题背景
内网把 Maven Central 镜像到本地,构建机只信任镜像。镜像网关每轮把上游模块搬到本地,做完整性校验、落盘 blob、生成镜像清单。
这次上游同步过来 6 个模块,其中 3 个是坏的:
| 模块 | 磁盘上的实际情况 | 上游公布的 .sha1 |
|---|---|---|
commons-lang3-3.12.0.pom | 正常 | 与内容一致 |
jackson-databind-2.13.4.pom | 正常 | 与内容一致,但是大写十六进制 |
slf4j-api-1.7.36.pom | 正常 | 与内容一致 |
guava-31.1-jre.pom | 内容被替换过 | 与内容不符 |
commons-io-2.6.pom | 传输中断被截断 | 与内容不符 |
junit-4.13.2.pom | 正常 | 根本没有 .sha1(上游只给 jar 发了校验和) |
期望:通过 3 个,拒收 3 个(guava / commons-io = mismatch,junit = no_digest)。
实际跑出来:
$ python3 -m app.run
{"blobs": 6, "files": 13, "modules": 6, "quarantined": 0, "rejected": 0, "verified": 6}
rejected=0 ok=True
6 个模块一个都没被拒收,ok: true,清单里 6 个模块全在,健康检查 {"degraded": false, "issues": []} 退出码 0。
构建机可以直接把被篡改的 guava 和被截断的 commons-io 当完好构件用。
唯一说真话的是外部独立探针(只读契约 + 镜像清单 + 磁盘字节,不读报告任何自述字段):
不一致 11 条:
- com/google/guava/guava/31.1-jre/guava-31.1-jre.pom.integrity:期望 'mismatch' 实际 'ok'
- junit/junit/4.13.2/junit-4.13.2.pom.integrity:期望 'no_digest' 实际 'ok'
- commons-io/commons-io/2.6/commons-io-2.6.pom.integrity:期望 'mismatch' 实际 'ok'
- counts.verified:期望 3 实际 6
- counts.rejected:期望 3 实际 0
- manifest.modules:期望 3 个 实际 6 个
- ok:期望 False 实际 True
RESULT: BAD(不一致 11 条)
二、复现步骤
环境:scenario/2026-10-19-1600/,python:3.12-slim,只依赖标准库。
cd scenario/2026-10-19-1600
docker compose up -d --build
docker compose exec -T mirror bash # 非交互必须加 -T
容器内(工作目录 /srv):
# ① 看问题现场
python3 -m app.run
{"blobs": 6, "files": 13, "modules": 6, "quarantined": 0, "rejected": 0, "verified": 6}
rejected=0 ok=True
# ② 外部探针(真正的验收口径)
python3 client/mirror_probe.py
# RESULT: BAD(不一致 11 条),rc=1
# ③ 复现脚本(退出码 = 探针退出码,问题态是 1)
bash ops/repro.sh ; echo "rc=$?"
# ④ 逐条验收:问题态 PASS=11 FAIL=16
bash ops/verify.sh
# ⑤ 健康检查是假绿
python3 -m app.health ; echo "rc=$?"
# {"degraded": false, "issues": []} rc=0
CONF_DIR=/tmp/nope python3 -m app.health ; echo "rc=$?"
# 契约读不到,照样 rc=0 —— 报平安
自己重算一遍事实,确认数据到底是什么状态:
python3 - <<'PY'
import hashlib, json, os
mirror = "mirror"
for m in json.load(open(f"{mirror}/listing.json"))["modules"]:
p = os.path.join(mirror, m["path"])
a = hashlib.sha1(open(p, "rb").read()).hexdigest()
d = open(p + ".sha1").read().strip().lower() if os.path.exists(p + ".sha1") else None
print("ok" if d == a else ("no_digest" if d is None else "mismatch"), m["path"], a, d)
PY
三、根本原因
对着契约(docs/mirror-contract.md)逐条读实现,一共 7 处坏味道,分三组。
3.1 完整性校验根本没在比较(app/gateway.py)
def _check_one(mirror_dir, path, cfg):
declared = _declared(mirror_dir, path, cfg)
data = loader.read_bytes(mirror_dir, path)
actual = digest_of(cfg["digest_algo"], data)
if cfg["trust_upstream_digest"] and declared is not None:
return "ok", declared, actual # ← 只要读到校验和就判 ok,从不比较!
return "ok", actual, actual # ← 没有校验和也判 ok
两句话说完:
- 「读取成功」被当成了「校验通过」。契约措辞是「校验和来源:读取镜像上
<artifact-file>.sha1的内容,该值必须由上游公布并被信任」—— 这是说「哪来的值可以用」,不是说「读到值就等于内容没问题」。上游公布的校验和只对上游自己发布的字节有意义, 它压根不能用来证明镜像里这份字节完好。这就是标题里的「上游校验和被当成信任初判」。 declared is None也走return "ok"。契约 §2 明写「<artifact-file>.sha1缺失或内容非法 → 不通过」, 诊断码no_digest。于是没有校验和的 junit 被当成完好构件。
而且函数永远只返回 "ok",契约要求的四档诊断码 ok / mismatch / no_digest / bad_digest 一个都没有。
normalize()(strip().lower())写得是对的,但因为从来不比较,等于没用上 ——
一旦改回真比较,lower() 必须留着,否则 jackson 的大写 .sha1 会被误判。
3.2 「株连降级」:一份坏模块废掉整批(app/gateway.py)
# 「早退」:一旦发现过不一致,就整批降级(后面的都不再单独判)
if rejected and integrity == "ok":
integrity = "mismatch"
declared = "--snip--"
actual2 = "--snip--"
违背契约 §2 的独立性条款:
一个模块的完整性判定只能取决于它的内容与它自己的校验和。不得因为「模块集合里某另一个模块校验和不符」而改变本模块的判定。 理由:镜像 gate 的职责是「发现这一份内容是否被篡改/截断」。若上游有一份坏数据,那是上游的事; 它不能让镜像对本来完好的其余模块失去判断能力(否则镜像一旦混入一份坏数据,整个镜像就永久无法自证,只能靠人工绕过闸门)。
这份数据集里 guava 排第 2、commons-io 排第 6,所以 guava 一判坏 →
后面 jackson / slf4j / junit / commons-io 全部被改写成 mismatch。
这是本题最危险的一条:即使你把「比较」修对了,只要留着这段,只要上游混进 1 份坏数据,
镜像就永久失去对其余模块的判断能力 —— 而运维的「解决办法」往往是「先把闸门绕过去」。
3.3 口径复用:去重粒度被当成了校验通过数(app/gateway.py)
"verified": len(seeded), # 校验通过数直接用「本轮动作条数」(blob 数)顶替
"blobs": len(seeded), # 同一个值
"ok": True, # 写死
"blob_created": True, # 写死
seeded 是去重键集合(内容哈希),它衡量的是「本轮新建了几个 blob」。
契约 §6 要求 counts.verified = 完整性判定通过的模块数。
两处巧到几乎不可能发现:本数据集 6 份内容互不相同 → len(seeded) == 6 == 模块数,
所以「6 个模块全部通过」在数字上完全自洽,verified + rejected == modules 也成立(6 + 0 = 6)。
账目自洽的谎话比账目打架的谎话难发现得多。
files 也有问题:loader.module_files() + 1 把同目录下别的模块文件也算进来(junit 目录里还有 jar 和 jar.sha1),
报 13,实际应是 11(6 个模块文件 + 5 个 .sha1,junit 的 pom 没有 .sha1)。
3.4 空判据表 + 退出码恒 0 的健康检查(app/health.py)
BAD_FIELDS = () # 判据表是空的:for 循环体执行 0 次
def main():
issues = []
...
for field in BAD_FIELDS: # 0 次
...
print(json.dumps({"degraded": bool(issues), "issues": issues}))
return EXIT_OK # 无论如何都返回 0
契约 §7 要求以事实层(契约 + 清单 + 磁盘文件)为判据独立给结论,三档退出码:
| 退出码 | 含义 |
|---|---|
| 0 | 干净一轮 |
| 2 | 有真问题:存在 rejected,或账目不自洽,或清单与磁盘不一致 |
| 3 | 判不了:契约不可用 / 不可解析 / 口径非法 |
for 执行 0 次 → issues 恒空 → degraded 恒 false;再加上无条件 return 0,
这份健康检查退化成「给自己发体检合格证」:契约读不到、报告被篡改、报告不存在,全都报健康。
一个关键细节:只比较
manifest与listing.json的条数也抓不到假绿(都是 6)。 判据必须落到磁盘字节 +.sha1上。
3.5 契约缺失静默回退引擎默认值(app/contract.py)
FALLBACK = {"digest_algo": "sha1", "trust_upstream_digest": True, ...}
def _raw(path):
try:
...
except OSError:
pass # ← 契约读不到就当空字典
return data
def load(conf_dir):
raw = _raw(...)
cfg = dict(FALLBACK) # ← 缺字段、缺整个文件,都拿引擎默认值兜底
cfg["digest_algo"] = raw.get("MIRROR_DIGEST_ALGO", FALLBACK["digest_algo"])
...
契约 §7 明写「判不了 ≠ 没问题:契约读不到时不得回退到 conf/gateway.env 的引擎默认值」。
后果:CONF_DIR=/tmp/nope python3 -m app.run 返回 0,用一套没人批准过的默认口径继续搬包。
另外 _bool() 对非法值直接 return False,MIRROR_TRUST_UPSTREAM_DIGEST=yes 被静默当成 false。
四、解决办法
只改 app/ 下三个文件(conf/、docs/、mirror/、client/、ops/ 一字未动 —— 改它们让判定变绿就是造假)。
4.1 app/gateway.py:把判定写成纯函数,一次只看一个模块
def judge(mirror_dir, path, cfg):
"""判定只看这个文件自己的字节 + 它自己的 <file>.sha1(契约 §2 独立性)。"""
data = loader.read_bytes(mirror_dir, path)
actual = digest_of(cfg["digest_algo"], data)
declared_raw = loader.read_digest_file(mirror_dir, path)
if declared_raw is None:
return "no_digest", None, actual, data
declared = normalize(declared_raw) # strip().lower()
if not _is_hex(declared, len(actual)):
return "bad_digest", declared, actual, data
if not cfg["trust_upstream_digest"]:
return "bad_digest", declared, actual, data # 不采信上游 → 无法自证 → 不通过
if declared == actual:
return "ok", declared, actual, data
return "mismatch", declared, actual, data
三个要点:
- 入参只有「自己」和「自己的校验和」,签名里根本没有
rejected/ 全局状态 —— 从函数签名上杜绝株连。 这比「写个注释说别株连」有用一百倍。 - 四档诊断码齐全,
bad_digest用「长度 + 字符集」校验,防止abc这种半截值蒙对。 - 大小写无关由
normalize()保证,jackson 的大写.sha1正常通过。
run_round() 改单趟循环,每个计数各归各位:
for item in listing:
path = item["path"]
integrity, declared, actual, data = judge(mirror_dir, path, cfg)
ok = integrity == "ok"
blob, created = None, False
if ok or cfg["blob_on_reject"]: # §4:被拦下的模块也落盘,便于人工复核
blob, created = _store_blob(blobs_dir, actual, data)
if created:
blobs_seen.add(actual) # §6:blobs 只数「本次新建」
if ok:
verified += 1 # §6:verified 是「判定通过的模块数」
status = "verified"
else:
rejected.append(path) # §4:无论落不落盘,判定都要保留
status = "quarantined"
if blob is not None:
quarantined += 1
files += _count_files(mirror_dir, path) # 自己的文件 + 自己的 .sha1
ok 改成算出来的:
"ok": len(rejected) == 0 and verified + len(rejected) == counts["modules"],
4.2 app/contract.py:fail-closed
- 删掉整个
FALLBACK字典; _raw()读不到文件直接raise ContractError;load()先检查REQUIRED五个字段都在,缺一个就抛;_bool()遇到非 true/false 抛错,不再静默False。
4.3 app/health.py:以事实层独立重算 + 三档退出码
_reference()只读listing.json+ 磁盘字节 +.sha1独立重算完整性,不复用gateway(复用被测代码 = 让实现自证实现,gateway里那个 bug 会被「一致地」复现,健康检查就抓不到)。_check()逐项对照:缺字段、counts.{modules,verified,rejected}、账目自洽 (verified+rejected==modules、quarantined<=rejected)、rejected列表、manifest.modules与sha1、ok, 以及每个模块落盘的 blob 真的存在且内容哈希对得上。- 退出码分层:
rc = EXIT_BAD if (issues or exp["rejected"] > 0) else EXIT_OK
这一行是关键:「有拒收」本身就是问题,不能因为「报告与重算一致」就放过。 (否则报告和重算都认为「10 个模块 3 个坏了」,两边一致 → 判「健康」,荒谬。)
五、验证
docker compose cp app/. mirror:/srv/app/ # 无 bind mount,必须同步进容器
docker compose exec -T mirror bash -c 'cd /srv && bash ops/repro.sh ; echo rc=$?'
{"blobs": 6, "files": 11, "modules": 6, "quarantined": 3, "rejected": 3, "verified": 3}
rejected=3 ok=False
- org/apache/commons/.../commons-lang3-3.12.0.pom verified ok
- com/google/guava/.../guava-31.1-jre.pom quarantined mismatch
- com/fasterxml/jackson/.../jackson-databind-2.13.4.pom verified ok ← 大写校验和通过
- org/slf4j/.../slf4j-api-1.7.36.pom verified ok
- junit/junit/4.13.2/junit-4.13.2.pom quarantined no_digest
- commons-io/commons-io/2.6/commons-io-2.6.pom quarantined mismatch
RESULT: OK
rc=0
bash ops/verify.sh # PASS=27 FAIL=0 / RESULT: OK
bash ops/negative.sh # NPASS=14 NFAIL=0 / RESULT: OK(闸门有牙)
python3 -m app.health ; echo rc=$? # degraded=true, rc=2(有拒收 → 2)
CONF_DIR=/tmp/nope python3 -m app.health ; echo rc=$? # rc=3(契约不可用 → 3)
CONF_DIR=/tmp/nope python3 -m app.run ; echo rc=$? # rc=3(run 也 fail-closed)
确定性(契约 §9):连跑两轮,报告逐字节相同。
从零重建复跑(确认不是缓存或手工现场造成的):
docker compose down && docker compose up -d --build
docker compose exec -T mirror bash -c 'cd /srv && bash ops/repro.sh >/dev/null; echo repro=$?; bash ops/verify.sh | tail -2; bash ops/negative.sh | tail -2'
# repro=0 / PASS=27 FAIL=0 / RESULT: OK / NPASS=14 NFAIL=0 / RESULT: OK(闸门有牙)
边界验证:
# trust_upstream_digest=false:契约要求不采信上游校验和 → 6 个模块全部无法自证,全部拒收
sed 's/^MIRROR_TRUST_UPSTREAM_DIGEST=.*/MIRROR_TRUST_UPSTREAM_DIGEST=false/' conf/mirror.env > /tmp/tc/mirror.env
CONF_DIR=/tmp/tc python3 -m app.run # {"quarantined": 6, "rejected": 6, "verified": 0}
# 布尔值非法 → 契约不合法
sed 's/^MIRROR_TRUST_UPSTREAM_DIGEST=.*/MIRROR_TRUST_UPSTREAM_DIGEST=yes/' conf/mirror.env > /tmp/tb/mirror.env
CONF_DIR=/tmp/tb python3 -m app.run ; echo rc=$?
# contract error: MIRROR_TRUST_UPSTREAM_DIGEST 不是布尔值: 'yes' rc=3
六、经验与避坑
核心认知
- 「读到校验和」≠「校验通过」。 契约说「校验和来源必须是上游公布并被信任的值」,
是在限定哪个值可以用,不是在说「读到就没事」。上游校验和只与上游自己发布的字节有意义,
它证明不了镜像里这份字节完好 —— 必须拿
sha1(本地字节)与声明值真比一次。 - 一份坏数据不能拖垮整个闸门的判断力。 校验是逐文件独立的判定;
任何「先发现一处不一致,然后整批降级 / 提前退出 / 全局短路」的写法,都会让镜像永久失去自证能力,
最终结果是运维把闸门绕过去 —— 比没闸门更危险。
从函数签名上防株连:判定函数的入参里不许出现
rejected、results、state这类全局状态。 - 去重粒度和校验通过数是两个维度。
blobs(本轮新建几个 blob,按内容哈希去重)和verified(几个模块判过关)在本数据集上数值都等于 6,于是可以互相顶替而不被算术抓住。 凡是同一个数字能同时解释两个字段的地方,都要问一句「它数的到底是件数还是条目数」。 - 账目自洽 ≠ 账目正确。
verified + rejected == modules在问题态是成立的(6 + 0 = 6), 因为两个加数都来自同一个错的口径。自洽约束只能防「数错」,防不了「量错了东西」。 - 空判据表是最隐蔽的假绿。
BAD_FIELDS = ()→for执行 0 次 →issues恒空 →degraded恒false, 再加无条件return 0。它比「没有健康检查」更危险,因为它静默地骗人:所有仪表盘全绿。 - 「判不了」必须和「没问题」用不同退出码。 契约不可用是
3,不是0。 配置读不到就回退默认值,等于让系统带着一套没人批准过的参数继续跑还报绿。 - 健康检查不能复用被测代码的判定逻辑。 一旦复用,被测代码里的 bug 会被健康检查「一致地」复现, 两边对上了 → 判健康。取事实、取时钟、判定语义都要自己写一遍。
踩坑记录
counts.blobs是「本次新建」不是「磁盘总数」。 同一容器连跑两轮,第二轮blobs变成 0 (blob 已存在)。我第一眼看到blobs: 0以为漏落盘,ls blobs才发现文件都在。 契约 §6 写的是「本次新建的 blob 数(去重之后)」,实现没改口径,别当成 bug「修」。docker compose cp后没有清__pycache__。 旧字节码会把「已改」掩盖成「没改」。ops/lib.sh::clean_pyc已内置,但手工python3 -m app.run时可能跑到旧代码。ops/negative.sh会把app/临时切成问题态基线,收尾才还原。 中途 Ctrl-C 会把你的修改冲掉 —— 重跑docker compose up -d --build拿干净基线,再docker compose cp app/. mirror:/srv/app/。ops/verify.sh --restore会用参考解覆盖你的app/(ops/expected-fixed/)。那是用来对照的,别在里面跑。MIRROR_BLOB_ON_REJECT=false时外部探针会误报。 探针只读/srv/conf/mirror.env, 把false硬编码成「不落盘也要有文件」,于是把合法的blob: null判成 BAD。 这是探针自身的单侧假设,不是实现问题(契约主数据集固定为true)。遇到时按契约 §4 的两种分支分别核对, 别去改实现迁就探针。counts.files别用「同目录文件数」。loader.module_files()返回的是同 stem 的所有文件, junit 目录里还有junit-4.13.2.jar/.jar.sha1,用它计数会把别的构件的文件算进这个模块。app/是COPY进镜像的、compose 无 bind mount。 改宿主机文件容器看不到, 必须docker compose cp或重建镜像,否则会出现「容器内全绿、宿主机git diff却是空的」这种极具迷惑性的现象。ops/repro.sh的退出码 = 探针退出码,问题态是 1(这是复现信号,不是环境坏了)。 用; echo rc=$?接住,别用&&否则后续全被中断。
自查口诀
我是在比较校验和,还是在读取校验和? 判定函数的入参里有别的模块吗? 没有校验和的时候我判通过还是不通过?
verified数的是模块还是 blob?counts.blobs是「本次新建」还是「磁盘总数」? 判据表是不是空的?那个for真的执行了吗? 有拒收的时候健康检查返回几? 契约读不到时我 fail-closed 还是用默认值继续跑? 健康检查是不是在拿被测代码的输出校验它自己? 把每个题眼逐一回滚,我的闸门会不会每一次都转红?
同类影响面
- 一切制品仓库 / 镜像仓库的完整性闸门:Maven
.sha1、npmintegrity、OCI digest、 下载站点SHA256SUMS、Git LFS、apt/yum 仓库签名 —— 校验必须逐文件独立、比较必须真比。 - 一切**「发现一个错误就整批放弃」的批处理**:批量导入先校验后写库、CI 的 lint 聚合、 多文件解析的 early-abort —— 一条坏数据不该让其余数据的结论变成「未知」。
- 一切为了性能做的 size/mtime 快判、blob 已存在即跳过的优化:
if blob_exists: continue会把「内容一样」和「校验过」混为一谈。 - 一切去重 / 缓存的键被复用成统计口径的地方(
len(cache)当「命中次数」、len(unique_ids)当「处理条数」)。 - 一切空判据表的健康检查 / 巡检脚本,以及**「读不到配置就回退默认值」的配置加载** —— 这类问题在所有有「绿灯文化」的运维体系里都会反复出现。
附:一键复现
git fetch origin && git checkout 2026-10-19-1600
cd scenario/2026-10-19-1600
docker compose up -d --build
docker compose exec -T mirror bash
# 容器内
cd /srv
bash ops/repro.sh ; echo "rc=$?" # 问题态 rc=1(探针 BAD 11 条)
bash ops/verify.sh # 问题态 PASS=11 FAIL=16
bash ops/negative.sh # NPASS=14 NFAIL=0(负向闸门)
修好后(本分支已包含修复):
bash ops/repro.sh ; echo "rc=$?" # rc=0,RESULT: OK
bash ops/verify.sh # PASS=27 FAIL=0
bash ops/negative.sh # NPASS=14 NFAIL=0
完整操作过程见 scenario/2026-10-19-1600/SOLUTION.md。
附二:质检修复轮次(fix_round 1)
质检实测 4 维度全过,只揪出 1 处文档硬错误 + 1 条建议,本轮修掉:
- 期望值表里
counts.files写成 13(问题态数字) → 改为 11。 13 是「问题态错误口径」的产物;按契约 §6「模块文件 + 各自的校验和文件各算一个」, 数据集是 6 个模块文件 + 5 个.sha1(junit 的 pom 没有.sha1)= 11。 用磁盘事实独立核对过总数,不是照抄实现输出。 教训:期望值表里的数字要贴着契约口径算一遍,从问题态输出里抓一个数字贴上去, 最容易把「bug 的产物」当成「正确答案」。 ops/verify.sh缺counts.files断言 —— 这就是上面那个错数字没被自动发现的根因。 补了 D6:counts.files == 11,并用「把 junit 的 jar 也算进去」的变异实测过它确实会转红 (期望 11 实际 12 → FAIL)。闸门覆盖不到的字段,文档口径写错了也没人拦。
计数同步:修复态 PASS=26 → PASS=27;问题态 PASS=11 FAIL=15 → PASS=11 FAIL=16。
附三:质检修复轮次(fix_round 2)
本轮质检只提 1 处:「experience/README.md 索引未收录第136题」,要求在第135题之后补一行。
核实结论:该问题在当前 HEAD 上已不成立(属误报)。逐条核对:
$ git rev-parse HEAD origin/main
bb541f0... bb541f0... # 本地与远端一致,工作区无未提交改动
$ git log -S'第136题' --oneline -- experience/README.md
f9891b3 docs(experience): 归档第136题经验文档(…)并更新索引 # 索引行由 f9891b3 引入,早于质检所指的 bb541f0
$ grep -c '第136题' experience/README.md
1
$ grep -c '2026-10-19-1600' experience/README.md
2 # ← 这就是下面的「建议改 2」:分支号同时命中索引行与主题,不能用来判收录
索引行紧跟第135题之后(行号会随新增期数漂移,别把行号写进结论;判位置请用检索式而不是绝对行号):
$ grep -nE '^\| \[第13[56]题\]' experience/README.md | cut -c1-40
203:| [第135题](./2026-10-19-1000-...
204:| [第136题](./2026-10-19-1600-...
本期索引行位于最后一行,5 列结构与表头一致:
期数 | [第136题](./2026-10-19-1600-…)、分支 `2026-10-19-1600`、主题、日期 2026-10-19、状态 已完成。
索引数据行合计 136 行,与 136 期一致;全部 ](./…) 链接目标文件都存在(0 条失效)。
误报的可能来源(已实证):BRE 里 ? 是字面量,用 grep -c '^| \[?第' 统计索引行会得到 0,
于是一眼看过去像「索引里一条都没有」。实测:
$ grep -c '^| \[?第' experience/README.md # BRE,? 是字面量 → 全不匹配
0
$ grep -cE '^\| (\[)?第' experience/README.md # ERE,正确
136
本轮实际改动(不重复插入索引行 —— 重复插入会把「误报」变成真实的重复索引):
experience/README.md约定区新增硬性要求「每期文档必须同时进索引表」,并把上面的自检命令固化成### 索引自检(提交前跑一遍)小节(行数 / 本期收录 / 最大题号 / 失效链接 4 条),附上 BRE?这个坑的说明,避免下次再拿一个恒为 0 的更严格的检索式去判「索引缺失」。- 本节记录核实过程,供下一轮质检直接复跑。
教训:「检索不到」先怀疑检索式,再怀疑数据 —— grep -c 返回 0 有两种含义:
「真没有」和「正则没匹配上」,而后者在 ?/+/( 这些 BRE 元字符上极常见。
判定「索引缺没缺」要用能命中已知行的正向对照:拿上一期(第135题)当阳性样本,
同一个检索式必须对它也返回 ≥1,否则检索式本身有问题。没有阳性对照的缺失判定不成立。
附四:质检修复轮次(fix_round 3)—— 订正附三的可核实性
上一轮质检(fix_round 2 之后)核实「第136题已收录、不重复插入索引」的结论成立, 但附三自身有 1 处可核实性硬伤 + 2 处命令瑕疵,本轮逐条订正:
- 【必须改】行号写错。附三原文写「第187行(紧跟第186行的第135题之后)」,实测第135题在
第203行、第136题在 第204行(
README.md共 204 行,本期索引行就是最后一行)。 订正方式:不再写绝对行号,改写成「紧跟第135题之后」,并给出可直接复跑的检索式 (grep -nE '^\| \[第13[56]题\]')—— 行号会随每期新增而漂移,写死行号等于给自己埋一条会过期的结论。 - 【建议改】收录自检式不唯一。
grep -c '2026-10-19-1600' README.md返回 2 (索引行的分支列 + 期数行主题里各出现一次),与文档里写的 1 不符。改为锚定行首只数数据行:
$ grep -c '2026-10-19-1600' experience/README.md # 2(分支号 + 主题)→ 不可用
$ grep -cE '^\| \[第136题\]' experience/README.md # 1 → 唯一命中
- 【建议改】失效链接检查被
for分词坑。原来的for f in $(...)靠空格分词, 文件名里一旦有空格就会被拆成两个路径,产生假MISSING。改成管道 +while IFS= read -r:
# 旧:for f in $(grep -o '](\./[^)]*)' README.md | sed ...); do ... done
# 新(输出为空 = 无失效):
grep -o '](\./[^)]*)' README.md | sed 's/](\.\///; s/)$//' | sort -u \
| while IFS= read -r f; do [ -f "$f" ] || echo "MISSING: $f"; done
负向自测(确认它真的会报,而不是永远安静):临时追一行带空格的目标
| [第999题](./no such file.md) | … | → 输出 MISSING: no such file.md;
删掉后再跑输出为空。只看到「输出为空」不足以证明检查有效,得先让它红一次。
订正后四条自检在当前 HEAD 的实测输出:
$ grep -cE '^\| (\[)?第' README.md # 136
$ grep -cE '^\| \[第136题\]' README.md # 1
$ grep -oE '^\| \[第[0-9]+题' README.md | grep -o '[0-9]\+' | sort -n | tail -1 # 136
$ grep -o '](\./[^)]*)' README.md | sed 's/](\.\///; s/)$//' | sort -u \
| while IFS= read -r f; do [ -f "$f" ] || echo "MISSING: $f"; done # 空 = 0 条失效
本轮教训:自我核实的文档,同样要经得起复跑 —— 写「第 N 行」这类绝对位置,
等于把结论绑定在一个每期都会变的事实上;写检索式、写「输出为空 = 无失效」这类
可判定的口径,下一轮质检才能 1:1 复现。另外统计口径要唯一:
grep -c '<分支号>' 命中 2 是数据的事实,不是文档写错,错的是选了不唯一的检索式 ——
「一个数字对不上」时先分辨是结论错还是口径错,别急着改结论。
