diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index de59ed39d..b6a9fd4e7 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -22,6 +22,10 @@ on: - 'tests/test725-agent-node-unit-ci/**' - 'tests/test745-agent-network-unit-ci/**' - 'tests/test746-setup-bun-pin/**' + - 'docs-site/**' + - 'docs/doc-source-pins-baseline.txt' + - 'scripts/check-doc-source-pins.py' + - 'tests/test831-doc-source-pins/**' push: branches: [main] paths: @@ -34,6 +38,10 @@ on: - 'tests/test725-agent-node-unit-ci/**' - 'tests/test745-agent-network-unit-ci/**' - 'tests/test746-setup-bun-pin/**' + - 'docs-site/**' + - 'docs/doc-source-pins-baseline.txt' + - 'scripts/check-doc-source-pins.py' + - 'tests/test831-doc-source-pins/**' # Older runs on the same ref get cancelled — saves minutes when a PR is # updated rapidly. main pushes run independently. @@ -105,3 +113,25 @@ jobs: echo "================ $f ================" tail -60 "$f" done + + doc-source-pins: + name: doc source-pin floor (Docker) + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + + # 🔴 这道门守的是下限,不是正确性:它只保证「已知失效的行号 pin 不再变多」。 + # 实测召回率 5/10 —— 抓不到「锚点指着一行正常代码、只是不是它声称的那 + # 一行」。见 #831 与 scripts/check-doc-source-pins.py 的文件头。 + # 别拿这个 job 的绿色去论证 #831 已解决。 + - name: Build test831-doc-source-pins + run: | + docker build \ + --build-arg TEST831_SOURCE_COMMIT="$GITHUB_SHA" \ + --build-arg TEST831_RUNSH_BLOB="$(git rev-parse "HEAD:tests/test831-doc-source-pins/run.sh")" \ + -t anet-test831-doc-source-pins \ + -f tests/test831-doc-source-pins/Dockerfile . + + - name: Run test831-doc-source-pins + run: docker run --rm --network none anet-test831-doc-source-pins diff --git a/docs/doc-source-pins-baseline.txt b/docs/doc-source-pins-baseline.txt new file mode 100644 index 000000000..952d0e369 --- /dev/null +++ b/docs/doc-source-pins-baseline.txt @@ -0,0 +1,47 @@ +# docs-site 里已知失效的源码行号 pin —— 基线,只许缩小 +# +# 生成自 scripts/check-doc-source-pins.py。见 #831。 +# +# 这里的每一条都指不到它声称的东西:要么文件/行号不存在,要么那一行是 +# `}` / `);` / 空行 / 某段注释的中间一行 —— 没有人会故意把说明文字的锚点 +# 钉在那种地方。 +# +# 🔴 这不是"全部失效的 pin",是"机械判据能证明失效的那部分"。 +# 实测召回率 5/10(用 #831 里已人工确认的 10 条回测),所以真实失效面 +# 比这 32 条大,大多少不知道。别把这个文件的行数当作 #831 的进度条。 +# +# 修一条就把它从这里删掉 —— 门会检查这一点,不删会红。 +# 修法见 #831:优先把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏)。 + +server/src/auth.ts#L7 +server/src/auth.ts#L99 +server/src/auth.ts#L102 +server/src/auth.ts#L184 +server/src/auth.ts#L243 +server/src/auth.ts#L269 +server/src/auth.ts#L374 +server/src/db.ts#L168 +server/src/index.ts#L253 +server/src/push.ts#L11 +server/src/push.ts#L35 +server/src/push.ts#L38 +server/src/tools.ts#L127 +server/src/tools.ts#L129 +server/src/tools.ts#L150 +server/src/tools.ts#L213 +server/src/tools.ts#L388 +server/src/tools.ts#L415 +server/src/tools.ts#L433 +server/src/tools.ts#L521 +server/src/tools.ts#L550 +server/src/tools.ts#L571 +server/src/tools.ts#L693 +server/src/tools.ts#L713 +server/src/tools.ts#L749 +server/src/tools.ts#L803 +server/src/tools.ts#L835 +server/src/tools.ts#L837 +server/src/tools.ts#L858 +server/src/tools.ts#L863 +server/src/tools.ts#L871 +server/src/tools.ts#L878 diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt new file mode 100644 index 000000000..795ea0c74 --- /dev/null +++ b/docs/tests/report-test831.txt @@ -0,0 +1,40 @@ +# report-test831 — doc source-pin floor gate +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=c6338f272a0474d84dc7f22c0fd482b9ca5de77a 构建) +# 本文件为套件原样输出,未手工编辑。 + +# test831 — doc source-pin floor gate +source_commit=c6338f272a0474d84dc7f22c0fd482b9ca5de77a +runsh_blob=b4ab91d3c5ef2016562657237c133ea70623aff9 +python=Python 3.12.13 +[L0] denominator + listing_mode=walk + scanned_doc_files=106 + pin_occurrences=141 + pins_on_immutable_ref=0 + pin_doc_pairs=139 + unique_pins=70 + broken_pins=32 + baseline_entries=32 + + OK: 失效 pin 32 个,基线 32 条 —— 没有新增,也没有该清的残留。 + 注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、 + 只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。 + OK walk 路径与 git 路径给出同一份清单(106 文件 / 70 唯一 pin / 141 处) +[L1] clean tree passes + OK rc=0 broken_pins=32(全部在基线里) +[L2] witnessed-red: a NEW broken pin must turn it red + MUTATION_RED new-out-of-range-pin rc=1 + 复原后回绿 ✓ +[L3] witnessed-red: a baseline entry whose link no longer exists must turn it red + MUTATION_RED stale-baseline-entry rc=1 + 复原后回绿 ✓ +[L4] the known blind spots are still blind (so the documented recall stays honest) + OK 3 条已知盲区仍未被判据覆盖(与文档里 5/10 的召回率一致) +[L5] the four review findings each have an assertion + ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿 + ② #L0 判为 line-out-of-range rc=1 + ④ 仓库外路径判为 path-escapes-repo rc=1 + ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin=agent-network/bin/cli.ts#L61) + 复原后回绿 ✓ +RESULT: PASS +exit_code=0 diff --git a/scripts/check-doc-source-pins.py b/scripts/check-doc-source-pins.py new file mode 100755 index 000000000..73994fbfa --- /dev/null +++ b/scripts/check-doc-source-pins.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""文档站里 blob//#L 形式的源码行号引用,有没有指到不存在的东西。 + +背景:#831。docs-site 下 141 处这类引用**全部**钉在 `main` 上,没有一个钉在 +不可变的 commit。钉 main 的锚点在每次重构后都会漂,而漂了不会有任何东西报错 —— +读者点进去看到的是一行毫不相干的代码,文档却仍然理直气壮。 + +🔴 这道门只抓得住其中一类,而且只有一半。务必先读下面这段再改它。 + +能抓到的(机械可证): + 1. 文件在树里不存在 + 2. 行号超出文件行数 + 3. 那一行是"平凡行" —— `}` / `}],` / `);` / 空行 / 某段注释的中间一行。 + 判据的理由:没有人会**故意**把一段说明文字的锚点钉在 `}` 或空行上。 + +抓不到的: + 锚点指着一行**长得很正常的代码**,只是不是它声称的那一行。 + 这是最常见的失效形态,只有人读了上下文才判得出。 + +召回率是实测的,不是估计的。拿 #831 里已人工确认失效的 10 条回测这套判据: + + tools.ts#L521 抓到(平凡行) + tools.ts#L286 ✘ 漏掉 network_id: z.string().max(200).optional(), + tools.ts#L646 ✘ 漏掉 cpu_pct: processCpuPct, + tools.ts#L571 抓到(平凡行) + tools.ts#L911 ✘ 漏掉 message_id: z.string().min(1).max(200), + tools.ts#L244 ✘ 漏掉 FROM skillhub_skills WHERE network_id = ?1`; + tools.ts#L271 ✘ 漏掉 const reviewer = !callerTokenIsNetwork && (role === … + auth.ts#L99 抓到(平凡行) + push.ts#L11 抓到(平凡行) + index.ts#L253 抓到(越界) + + 抓到 5/10,漏掉 5/10 + +**所以这道门全绿不等于文档站的行号引用是对的。** 它承诺的只有一件事: +已知失效的那批不会变多。别把它当成 #831 的解决方案 —— #831 的解决方案是 +把行号锚点换成符号锚点,这道门只是在那之前守住下限。 + +基线的语义:docs/doc-source-pins-baseline.txt 记着当前已知失效的那批。 + - 出现基线之外的新失效 → 红。这是这道门存在的理由。 + - 基线里的某条已经修好 → 也红,并要求把它从基线里删掉。 + 不这么做的话基线会变成坟场:修好的和没修的混在一起,数字再也不说明任何事。 +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() +BASELINE = REPO / "docs" / "doc-source-pins-baseline.txt" +DOC_ROOT = "docs-site" + +PIN = re.compile( + r"https://github\.com/sleep2agi/agent-network/blob/" + r"([0-9a-zA-Z._-]+)/([^\s)#\"']+)#L(\d+)" +) + +# 平凡行:只有闭合符号、空白,或整行是注释。 +# 整行注释也算"平凡",因为锚点落在一段注释的中间一行,几乎总是漂移的结果 —— +# 引用一段注释时人会锚在它的第一行。这条会有误判,所以它进的是基线而不是硬红。 +TRIVIAL = re.compile(r"^\s*(?:[}\])]+[;,)]*\s*|//.*|/\*.*|\*.*)?$") + +SCANNED_SUFFIXES = (".md", ".ts", ".tsx", ".js", ".json", ".vue") + + +def tracked_docs() -> tuple[list[str], str]: + """返回 (待扫文件列表, 用的是哪条路径)。 + + 有 .git 就用 git ls-files —— 那是权威的"仓里有什么"。 + 没有 .git(比如在只 COPY 了源码树的容器里)就退化成目录遍历。 + + 两条路径在干净检出上应当给出同一份清单。tests/test831-doc-source-pins + 的 L0 会同时跑这两条并断言文件数相等 —— 否则容器内外扫的范围会悄悄分叉, + 而"容器里绿"就不再能推出"仓库里绿"。 + """ + if (REPO / ".git").exists(): + out = subprocess.run( + ["git", "-C", str(REPO), "ls-files", DOC_ROOT], + capture_output=True, text=True, check=True, + ).stdout.split() + return ([f for f in out if f.endswith(SCANNED_SUFFIXES)], "git") + + root = REPO / DOC_ROOT + walked = [ + str(path.relative_to(REPO)) + for path in sorted(root.rglob("*")) + if path.is_file() + and path.name.endswith(SCANNED_SUFFIXES) + and "node_modules" not in path.parts + and ".vitepress/cache" not in str(path) + and "dist" not in path.parts + ] + return (walked, "walk") + + +IMMUTABLE_REF = re.compile(r"^[0-9a-f]{7,40}$") + + +def collect_pins(files: list[str]) -> tuple[dict[tuple[str, int], set[str]], int, int]: + """返回 (pin → 引用它的文档集合, 原始出现次数)。 + + 两个数是不一样的,别混:同一个 pin 在同一个文档里出现两次,前者只记一次。 + 第一版这里把 sum(len(v)) 当成了"引用总数"打印出来,得到 139,而原始出现 + 次数是 141 —— 差的两处正是同文件内的重复。数对不上是自己发现的:同一份 + 数据我先后量出两个数。 + """ + pins: dict[tuple[str, int], set[str]] = {} + occurrences = 0 + pinned_to_sha = 0 + for rel in files: + try: + text = (REPO / rel).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for match in PIN.finditer(text): + ref, path, line = match.groups() + occurrences += 1 + # 🔴 审查指出的:第一版把 ref 丢掉,一律拿当前检出去解析。那会让 + # 「按本工具的建议改成钉不可变 commit」的引用被误判 —— 那条链接 + # 在它自己的 commit 上是对的,在 HEAD 上未必。反过来,一条在历史 + # 版本里就错的链接,也可能因为 HEAD 恰好长得对而蒙混过关。 + # 这道门管的是**会漂的引用**;钉了 SHA 的不在范围内,单独计数。 + if IMMUTABLE_REF.match(ref): + pinned_to_sha += 1 + continue + pins.setdefault((path, int(line)), set()).add(rel) + return pins, occurrences, pinned_to_sha + + +def classify(path: str, line: int) -> tuple[str, str] | None: + """返回 (失效类别, 那一行的内容);指不出问题时返回 None。""" + # 🔴 文档里写 `blob/main/../../etc/passwd#L1` 时,直接拼到 REPO 上会读出 + # 仓库外的文件,而 /etc/passwd 第一行非平凡 —— 一个根本不指向本仓的链接 + # 就被判成健康。先拒绝绝对路径与 .. 分量,再核解析后仍在 REPO 之下。 + if path.startswith("/") or ".." in Path(path).parts: + return ("path-escapes-repo", path) + target = REPO / path + try: + target.resolve().relative_to(REPO) + except ValueError: + return ("path-escapes-repo", path) + if not target.is_file(): + return ("missing-file", "") + try: + content = target.read_text(encoding="utf-8").split("\n") + except (OSError, UnicodeDecodeError): + return ("unreadable", "") + # 行号是 1-based。第一版只挡了上界,#L0 会走到 content[-1] 读最后一行, + # 于是一个畸形锚点在最后一行非平凡时被判成健康。 + if line < 1 or line > len(content): + return ("line-out-of-range", f"(行号 {line},文件 {len(content)} 行)") + text = content[line - 1] + if TRIVIAL.match(text): + return ("trivial-line", text.strip()) + return None + + +def read_baseline() -> set[str]: + if not BASELINE.is_file(): + return set() + entries = set() + for raw in BASELINE.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() if raw.lstrip().startswith("#") else raw.strip() + if line and not raw.lstrip().startswith("#"): + entries.add(line) + return entries + + +def main() -> int: + files, mode = tracked_docs() + pins, occurrences, pinned_to_sha = collect_pins(files) + print(f"listing_mode={mode}") + print(f"scanned_doc_files={len(files)}") + print(f"pin_occurrences={occurrences}") + print(f"pins_on_immutable_ref={pinned_to_sha}") + print(f"pin_doc_pairs={sum(len(v) for v in pins.values())}") + print(f"unique_pins={len(pins)}") + + # 分母承重:扫不到任何 pin 时不能报绿 —— 那多半是 DOC_ROOT 写错或后缀表漏了, + # 而"零个坏 pin"和"根本没扫到东西"打印出来是同一片绿。 + if not pins: + print("FAIL: 一个 pin 都没扫到 —— 检查 DOC_ROOT / SCANNED_SUFFIXES 是不是坏了", file=sys.stderr) + return 1 + + broken: dict[str, tuple[str, str]] = {} + for (path, line), _docs in sorted(pins.items()): + verdict = classify(path, line) + if verdict: + broken[f"{path}#L{line}"] = verdict + + print(f"broken_pins={len(broken)}") + + baseline = read_baseline() + print(f"baseline_entries={len(baseline)}") + + # 🔴 审查指出的第三条,这里的语义第一版是错的。 + # 原来写的是 fixed = baseline - broken:只要判据不再标某条,就叫人删基线。 + # 但代码一漂,一个仍然错的锚点会从「平凡行」挪到「普通但不相干的一行」, + # 判据就不标它了 —— 而文档一个字没动,链接还是错的。照原来的规则,CI 会 + # 主动要求把这条已知缺陷从基线里删掉,等于把它推进本工具自己的盲区。 + # + # 正确的语义:基线条目只在**文档里那个引用不存在了**时才该删。 + present = {f"{path}#L{line}" for (path, line) in pins} + new = sorted(set(broken) - baseline) + gone = sorted(baseline - present) + # 仍被文档引用、但判据已经标不出来的 —— 不删,也不算绿,单独列出来。 + drifted = sorted((baseline & present) - set(broken)) + + if drifted: + print() + print(f"⚠️ {len(drifted)} 个基线条目仍被文档引用,但判据已经标不出它们了:") + for key in drifted: + print(f" {key}") + print(" 这通常意味着源码漂移把锚点从「平凡行」挪到了「普通但不相干的一行」——") + print(" 链接**仍然是错的**,只是这套判据看不见了。保留在基线里,别删。") + + if new: + print() + print(f"FAIL: {len(new)} 个新的失效 pin(不在基线里)", file=sys.stderr) + for key in new: + kind, text = broken[key] + print(f" [{kind}] {key} {text}", file=sys.stderr) + print(file=sys.stderr) + print(" 改法:把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏),", file=sys.stderr) + print(" 或者钉一个不可变的 commit SHA。别把新条目加进基线 —— 基线只许缩小。", file=sys.stderr) + + if gone: + print() + print(f"FAIL: {len(gone)} 个基线条目对应的引用已经不在文档里了,请从基线里删掉", file=sys.stderr) + for key in gone: + print(f" {key}", file=sys.stderr) + print(file=sys.stderr) + print(" 不删的话基线会变成坟场:修好的和没修的混在一起,数字再也不说明任何事。", file=sys.stderr) + + if new or gone: + return 1 + + print() + print(f"OK: 失效 pin {len(broken)} 个,基线 {len(baseline)} 条 —— 没有新增,也没有该清的残留。") + print("注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、") + print("只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test831-doc-source-pins/Dockerfile b/tests/test831-doc-source-pins/Dockerfile new file mode 100644 index 000000000..d697d11dc --- /dev/null +++ b/tests/test831-doc-source-pins/Dockerfile @@ -0,0 +1,33 @@ +# test831 —— 文档站源码行号 pin 的下限门 +# +# 见 #831:docs-site 下 141 处 blob//#L 引用全部钉在 main 上, +# 没有一个钉在不可变 commit。钉 main 的锚点每次重构都会漂,而漂了不报错。 +# +# 🔴 这道门不解决 #831,只守住下限:已知失效的那批不会变多。 +# 它的召回率是实测的 5/10,判据与边界写在 scripts/check-doc-source-pins.py +# 的文件头,改这道门之前先读那段。 + +# 第一版这里写了一个我编造的 python:3.12-slim digest —— 那串 sha256 不对应任何 +# 真实镜像。换成本地实际拉到并核对过的 alpine:3.20,python3 由 apk 装(3.12.13)。 +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc + +RUN apk add --no-cache python3 bash >/dev/null + +WORKDIR /repo +ARG TEST831_SOURCE_COMMIT=unknown +ENV TEST831_SOURCE_COMMIT=$TEST831_SOURCE_COMMIT +ARG TEST831_RUNSH_BLOB=unknown +ENV TEST831_RUNSH_BLOB=$TEST831_RUNSH_BLOB + +# 被扫的对象 +COPY docs-site /repo/docs-site +# pin 指向的源码树 —— 不 COPY 的话每个 pin 都会被判成 missing-file, +# 门会红在一个纯粹是镜像装配错误的原因上。 +COPY server /repo/server +COPY agent-network /repo/agent-network +COPY docs/doc-source-pins-baseline.txt /repo/docs/doc-source-pins-baseline.txt +COPY scripts/check-doc-source-pins.py /repo/scripts/check-doc-source-pins.py +COPY tests/test831-doc-source-pins /repo/tests/test831-doc-source-pins +RUN chmod +x /repo/tests/test831-doc-source-pins/run.sh + +CMD ["bash", "/repo/tests/test831-doc-source-pins/run.sh"] diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh new file mode 100755 index 000000000..b4ab91d3c --- /dev/null +++ b/tests/test831-doc-source-pins/run.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +# test831 —— 文档站源码行号 pin 的下限门(见 #831) +# +# 🔴 这道门证明的是「已知失效的那批不会变多」,不是「文档站的行号引用是对的」。 +# 判据的召回率实测 5/10,边界写在 scripts/check-doc-source-pins.py 头部。 +# 别在别处引用这道门的绿色去论证「#831 已解决」。 + +ROOT=/repo +CHECK="$ROOT/scripts/check-doc-source-pins.py" +BASELINE="$ROOT/docs/doc-source-pins-baseline.txt" + +SOURCE_COMMIT=${TEST831_SOURCE_COMMIT:-} +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: SOURCE_COMMIT must be one full lowercase Git SHA" >&2; exit 1; } + +# 与 test798 / test812 同:验 SHA 的格式不够,任何 40 位十六进制都能过。 +# 把 run.sh 在该 commit 下的 git blob 哈希传进来就地重算比对。 +RUNSH_BLOB=${TEST831_RUNSH_BLOB:-} +[[ "$RUNSH_BLOB" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: TEST831_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节" >&2; exit 1; } +_self="$ROOT/tests/test831-doc-source-pins/run.sh" +_actual=$( { printf 'blob %d\0' "$(wc -c < "$_self")"; cat "$_self"; } | sha1sum | cut -d' ' -f1 ) +[[ "$_actual" == "$RUNSH_BLOB" ]] || { + echo "FAIL: 镜像里的 run.sh 与 SOURCE_COMMIT=$SOURCE_COMMIT 声称的不是同一份" >&2 + echo " 期望 blob $RUNSH_BLOB,实际 $_actual" >&2; exit 1; } + +echo "# test831 — doc source-pin floor gate" +echo "source_commit=$SOURCE_COMMIT" +echo "runsh_blob=$_actual" +echo "python=$(python3 -V 2>&1)" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# L0 — 分母。镜像里没有 .git,checker 走的是目录遍历那条路径。 +# 这里把它实际扫到的数字打出来并与预期比对 —— 否则「扫到 0 个文件」和 +# 「0 个坏 pin」打印出来是同一片绿。 +# --------------------------------------------------------------------------- +echo "[L0] denominator" +out=$(python3 "$CHECK" "$ROOT") +printf '%s\n' "$out" | sed 's/^/ /' + +mode=$(printf '%s' "$out" | sed -nE 's/^listing_mode=(.*)$/\1/p') +files=$(printf '%s' "$out" | sed -nE 's/^scanned_doc_files=([0-9]+)$/\1/p') +uniq=$(printf '%s' "$out" | sed -nE 's/^unique_pins=([0-9]+)$/\1/p') +occ=$(printf '%s' "$out" | sed -nE 's/^pin_occurrences=([0-9]+)$/\1/p') +broken=$(printf '%s' "$out" | sed -nE 's/^broken_pins=([0-9]+)$/\1/p') + +[[ "$mode" == "walk" ]] || fail "镜像里应当走目录遍历,实际 listing_mode=$mode" +[[ "${files:-0}" -gt 0 ]] || fail "扫到 0 个文档文件 —— 分母塌了" +[[ "${uniq:-0}" -gt 0 ]] || fail "扫到 0 个 pin —— 分母塌了" + +# 容器内遍历得到的数字必须与仓库里 git ls-files 得到的一致,否则容器内外 +# 扫的范围分叉,「容器里绿」就推不出「仓库里绿」。这两个数是写死的预期值, +# 变了要人来确认是真变了还是扫漏了。 +[[ "$files" -eq 106 ]] || fail "预期扫 106 个文档文件(= git ls-files 的结果),实际 $files" +[[ "$uniq" -eq 70 ]] || fail "预期 70 个唯一 pin,实际 $uniq" +[[ "$occ" -eq 141 ]] || fail "预期 141 处原始出现,实际 $occ" +echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 70 唯一 pin / 141 处)" + +# --------------------------------------------------------------------------- +# L1 — 干净树上必须绿 +# --------------------------------------------------------------------------- +echo "[L1] clean tree passes" +python3 "$CHECK" "$ROOT" >/dev/null || fail "干净树上这道门就红了" +echo " OK rc=0 broken_pins=$broken(全部在基线里)" + +# --------------------------------------------------------------------------- +# L2 — witnessed-red ①:新增一个坏 pin,必须红,且红在「新的失效 pin」上 +# --------------------------------------------------------------------------- +echo "[L2] witnessed-red: a NEW broken pin must turn it red" +VICTIM=$(find "$ROOT/docs-site" -name '*.md' | sort | head -1) +[[ -n "$VICTIM" ]] || fail "找不到可用于变异的文档" +cp "$VICTIM" /tmp/victim.bak +before=$(sha256sum "$VICTIM" | cut -d' ' -f1) +# 指向一个必然越界的行号 —— server/src/index.ts 在 main 上只有十几行。 +printf '\n[bogus](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts#L99999)\n' >> "$VICTIM" +after=$(sha256sum "$VICTIM" | cut -d' ' -f1) +[[ "$before" != "$after" ]] || fail "变异是字节 no-op" + +set +e +mut=$(python3 "$CHECK" "$ROOT" 2>&1); rc=$? +set -e +cp /tmp/victim.bak "$VICTIM" +[[ "$rc" -ne 0 ]] || fail "新增坏 pin 之后这道门仍然绿" +printf '%s' "$mut" | grep -qF "个新的失效 pin" \ + || fail "红了,但不是红在「新的失效 pin」上:$(printf '%s' "$mut" | head -3)" +printf '%s' "$mut" | grep -qF "line-out-of-range" \ + || fail "红了,但没有把类别判成 line-out-of-range" +echo " MUTATION_RED new-out-of-range-pin rc=$rc" + +# 复原后必须回绿 —— 不然上面那个红可能是变异之外的东西造成的 +python3 "$CHECK" "$ROOT" >/dev/null || fail "复原之后没有回绿,说明 L2 的红不止来自变异" +echo " 复原后回绿 ✓" + +# --------------------------------------------------------------------------- +# L3 — witnessed-red ②:基线里塞一条并不失效的条目,必须红。 +# 这一条守的是「基线只许缩小」:没有它,基线会慢慢变成坟场。 +# --------------------------------------------------------------------------- +echo "[L3] witnessed-red: a baseline entry whose link no longer exists must turn it red" +cp "$BASELINE" /tmp/baseline.bak +printf 'server/src/auth.ts#L1\n' >> "$BASELINE" +set +e +mut2=$(python3 "$CHECK" "$ROOT" 2>&1); rc2=$? +set -e +cp /tmp/baseline.bak "$BASELINE" +[[ "$rc2" -ne 0 ]] || fail "基线里混进一条不失效的条目,门却是绿的" +# 注意文案:#843 的审查之后,判据改成「引用还在不在文档里」,不再是 +# 「判据还标不标它」。塞进去的 auth.ts#L1 任何文档都没引用,所以走这条。 +printf '%s' "$mut2" | grep -qF "对应的引用已经不在文档里了" \ + || fail "红了,但不是红在「基线条目对应的引用已不在文档里」上:$(printf '%s' "$mut2" | head -3)" +echo " MUTATION_RED stale-baseline-entry rc=$rc2" + +python3 "$CHECK" "$ROOT" >/dev/null || fail "复原基线后没有回绿" +echo " 复原后回绿 ✓" + +# --------------------------------------------------------------------------- +# L4 — 判据的边界要能被别人看见,而不是只写在源码注释里。 +# #831 里已人工确认失效、但这套机械判据抓不到的那几条,必须仍然判不出来。 +# 这条断言存在的意义是:哪天有人"改进"了判据,这里会红,提醒他去更新 +# 文档里那个 5/10 的召回率数字,而不是让边界悄悄漂移。 +# --------------------------------------------------------------------------- +echo "[L4] the known blind spots are still blind (so the documented recall stays honest)" +blind_ok=0 +for pin in "server/src/tools.ts#L286" "server/src/tools.ts#L646" "server/src/tools.ts#L911"; do + path=${pin%%#*}; line=${pin##*#L} + if grep -qxF "$pin" "$BASELINE"; then + fail "$pin 出现在基线里 —— 它本应是判据抓不到的那一类,边界变了" + fi + [[ -f "$ROOT/$path" ]] || fail "$path 不在镜像里" + blind_ok=$((blind_ok+1)) +done +echo " OK $blind_ok 条已知盲区仍未被判据覆盖(与文档里 5/10 的召回率一致)" + +# --------------------------------------------------------------------------- +# L5 — #843 审查提的四条,各自一个断言。修了判据却没有断言,等于没修。 +# 每条都用"注入 → 期望的红/绿 → 复原 → 回绿"的形状。 +# --------------------------------------------------------------------------- +echo "[L5] the four review findings each have an assertion" +VICTIM2=$(find "$ROOT/docs-site" -name '*.md' | sort | head -1) +cp "$VICTIM2" /tmp/victim2.bak +restore2() { cp /tmp/victim2.bak "$VICTIM2"; } + +# ① 钉了不可变 SHA 的引用不属于这道门 —— 注入一个"在 HEAD 上必然越界"的 SHA pin, +# 门必须**仍然绿**(它管的是会漂的 main 引用,不是历史链接)。 +printf '\n[sha](https://github.com/sleep2agi/agent-network/blob/0123456789abcdef0123456789abcdef01234567/server/src/index.ts#L99999)\n' >> "$VICTIM2" +set +e; out5=$(python3 "$CHECK" "$ROOT" 2>&1); rc5=$?; set -e +restore2 +[[ "$rc5" -eq 0 ]] || fail "① 钉 SHA 的引用被当成漂移失效了(rc=$rc5)—— 那会惩罚按本工具建议做出的修改" +printf '%s' "$out5" | grep -q "pins_on_immutable_ref=1" \ + || fail "① 钉 SHA 的引用没有被单独计数:$(printf '%s' "$out5" | grep pins_on_immutable || true)" +echo " ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿" + +# ② #L0 必须判成越界。第一版只挡上界,content[-1] 会读到最后一行。 +printf '\n[zero](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L0)\n' >> "$VICTIM2" +set +e; out6=$(python3 "$CHECK" "$ROOT" 2>&1); rc6=$?; set -e +restore2 +[[ "$rc6" -ne 0 ]] || fail "② #L0 没被判成失效 —— 行号是 1-based,0 会读到最后一行" +printf '%s' "$out6" | grep -q "line-out-of-range" || fail "② #L0 红了但类别不是 line-out-of-range" +echo " ② #L0 判为 line-out-of-range rc=$rc6" + +# ④ 路径穿越:仓库外的文件不能被当成健康锚点。 +printf '\n[esc](https://github.com/sleep2agi/agent-network/blob/main/../../etc/passwd#L1)\n' >> "$VICTIM2" +set +e; out7=$(python3 "$CHECK" "$ROOT" 2>&1); rc7=$?; set -e +restore2 +[[ "$rc7" -ne 0 ]] || fail "④ ../../etc/passwd 被判成健康锚点了" +printf '%s' "$out7" | grep -q "path-escapes-repo" || fail "④ 红了但类别不是 path-escapes-repo" +echo " ④ 仓库外路径判为 path-escapes-repo rc=$rc7" + +# ③ 基线语义:条目只在"文档里那个引用没了"时才该删。 +# 造法:把一条基线条目对应的引用留在文档里,但让判据标不出它 —— +# 直接往基线里塞一条指向非平凡行、且文档里确实引用着的 pin。 +# 期望:不红(它没消失),但要出现 drifted 警告。 +DRIFT_PIN=$(python3 - "$ROOT" <<'PYX' +import re,sys,pathlib +root=pathlib.Path(sys.argv[1]) +PIN=re.compile(r"blob/main/([^\s)#\"']+)#L(\d+)") +base={l.strip() for l in (root/'docs/doc-source-pins-baseline.txt').read_text(encoding='utf-8').splitlines() + if l.strip() and not l.lstrip().startswith('#')} +for f in (root/'docs-site').rglob('*.md'): + for m in PIN.finditer(f.read_text(encoding='utf-8')): + k=f"{m.group(1)}#L{m.group(2)}" + if k not in base: + print(k); sys.exit(0) +PYX +) +[[ -n "$DRIFT_PIN" ]] || fail "③ 找不到一个「文档引用着但不在基线里」的 pin 来造场景" +cp "$BASELINE" /tmp/baseline2.bak +printf '%s\n' "$DRIFT_PIN" >> "$BASELINE" +set +e; out8=$(python3 "$CHECK" "$ROOT" 2>&1); rc8=$?; set -e +cp /tmp/baseline2.bak "$BASELINE" +[[ "$rc8" -eq 0 ]] || fail "③ 引用仍在文档里、只是判据标不出来,不该红(rc=$rc8):$(printf '%s' "$out8" | head -3)" +printf '%s' "$out8" | grep -qF "仍被文档引用" \ + || fail "③ 没有给出 drifted 警告 —— 这条会被悄悄当成'修好了'" +echo " ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin=$DRIFT_PIN)" + +python3 "$CHECK" "$ROOT" >/dev/null || fail "L5 复原之后没有回绿" +echo " 复原后回绿 ✓" + +echo "RESULT: PASS"