Files
HomeAgent/.github/scripts/check-ci-gates.py
JianFeeeee 53cdc7f2a6 refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口
## 用户裁定(本次唯一方向)

1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」

## 实测起点

    537 个测试文件 / 377 个源码文件 / 2853 条 Test
    523 / 537(97%)直接调用私有函数
    514 个是 in-package 白盒(package xxx,与源码同目录)
     88 个读 .go/.md 源码做断言
     94 个断言私有字段(f.Step / r.buf / a.sched …)
     38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
    internal/agent/core: 140 test / 52 src

## 做了什么

### ① 删除 514 个 in-package 白盒测试

不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。

用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:

    internal/agent/core/child/test/     Registry 的 Add/Get/Stop/Remove 输出
    internal/agent/core/test/           StageHost 的 Register/Run 输出
    internal/plugins/files/test/        工具 handler 的 {path} → {content/error}
    internal/plugins/webui/test/        HTTP 请求 → 响应(NewHandler/RegisterRoutes)
    internal/tfidf/test/                文本 → 向量/检索结果

现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。

### ② 回退「为测试而导出」的接口

这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:

    webui.FrontendAsset / DashboardPage        删除
    webui.GraphDataForVisual → graphDataForVisual
    webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
    webui.NewSSEEventRing                       删除
    kstate.MountForTest                         删除
    security.ClassifyPrincipalForTest           删除

生产代码对它们的内部调用同步改回私有名,不改任何行为。

### ③ CI 一鼓作气改到位(用户指定顺序)

**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。

**加两道构建期静态门**(原先这些约束错放在 go test 里):

- `测试归拢(源码目录不得有 _test.go)`
  `find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
  非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
  用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
  这是**工具链**的职责,不该由 `go test` 兼职。

`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。

### ④ 顺带(上一批未提交的)

`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。

## 验证

    go build ./...                     PASS
    go vet ./...                       PASS
    gofmt -l                           干净
    python3 .github/scripts/check-ci-gates.py   PASS
    go test ./... -count=1             5 个测试包全过 / 100 个包无测试 / 0 FAIL
    源码目录 _test.go 数              0
    测试文件数                         9(全部在 <module>/test/)

## 后续

按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
2026-11-09 11:13:56 +08:00

314 lines
16 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""CI 门禁自检:断言 ci.yml 的关键机制仍在。退出码 0 = 通过。
## 为什么用 YAML 解析,而不是 grep 文本
本次改动的注释里**刻意保留了旧写法的引述**(例如
「原先这里写死 ./internal/agent/core/ ./cmd/waiter/ 两个包」)。
于是 grep 会两头骗人:
· 断言「旧写法不存在」→ 注释里那句引述让它**假红**
· 断言「新机制存在」 → 注释里写一句同名机制就能让它**假绿**
yaml.safe_load 丢掉全部注释,只留可执行内容 ⇒ 两类误判同时消失。
## 这条判据防的是什么
CI 配置是「静默退化」的高发区:把动态枚举改回写死包列表,
流水线照样绿(只是罩得少了),没人会发现。所以必须有人盯着
「动态枚举还在」「go test 真的吃了枚举结果」这两件事。
"""
import sys
import yaml
PATH = ".github/workflows/ci.yml"
fails = []
def check(cond, msg):
if not cond:
fails.append(msg)
with open(PATH, encoding="utf-8") as f:
doc = yaml.safe_load(f)
jobs = (doc or {}).get("jobs") or {}
check(bool(jobs), "YAML 解析后没有 jobs —— 文件结构坏了")
LEGACY_RACE_PKGS = "./internal/agent/core/ ./cmd/waiter/"
def code_lines(run):
"""只保留**可执行**行:剥掉 shell 注释。
★ 为什么必须这么干(本条判据自己栽过的坑):
YAML 解析只丢掉了 YAML 级注释,但 `run: |` 块里的 `# ...`
是**字符串内容**,照样落在 run 文本里。
于是 `"cmd/gui" in run` 会被一句说明性注释满足 ——
真把 `grep -v '^.*/cmd/gui$'` 删掉,断言仍然绿。
判据自己被注释骗过去,和被测代码骗判据是同一类错误。
"""
out = []
for line in run.splitlines():
if line.lstrip().startswith("#"):
continue
out.append(line)
return "\n".join(out)
def step_runs(job):
out = []
for st in (jobs.get(job) or {}).get("steps") or []:
run = st.get("run")
if run:
out.append((st.get("name") or "", run))
return out
# ── 1. race job:包列表必须动态枚举,并真的剔除 cmd/gui ──
race = step_runs("race")
enum = [(n, r) for n, r in race if "go list ./..." in r and "GITHUB_OUTPUT" in r]
check(len(enum) == 1, "race job 应恰有一步用 `go list ./...` 动态枚举包(实际 %d 步)" % len(enum))
if enum:
# ★ 用 code_lines 剥掉 shell 注释后再断言,否则注释里的 "cmd/gui" 会顶包
check("cmd/gui" in code_lines(enum[0][1]),
"race 枚举步骤必须显式剔除 cmd/gui(否则 go test 报 no Go files 的假红)")
# ── 2. 真正的 go test -race 必须吃枚举结果,不许写死包列表 ──
#
# ★ 只认「全仓主步骤」:race job 里还有一步是**单包取证**(只跑 webui 那一条
# 已知竞争),它本来就不该吃全仓枚举。初版脚本没区分这两类,把取证步骤
# 也套上「必须吃枚举」的断言 ⇒ 误报红。判据自己误报同样是缺陷(会让人
# 习惯性忽略红),所以这里按「步骤是否消费 PKGS 变量」来分流。
main_race = [(n, r) for n, r in race
if "go test -race" in r and "$PKGS" in r]
check(len(main_race) == 1,
"race job 应恰有一步全仓 go test -race 消费 $PKGS(实际 %d 步)" % len(main_race))
for _n, r in main_race:
check("steps.pkgs.outputs.pkgs" in code_lines(r),
"全仓 go test -race 必须使用枚举结果 steps.pkgs.outputs.pkgs")
check(LEGACY_RACE_PKGS not in code_lines(r),
"全仓 go test -race 里出现了旧的写死包列表 " + LEGACY_RACE_PKGS)
# 取证步骤:允许单包跑,但必须写明修复单号(否则排除项成了无主的掩盖)
evid = [(n, r) for n, r in race if "go test -race" in r and "$PKGS" not in r]
for _n, r in evid:
check("DATA RACE" in r,
"取证步骤应显式检查 DATA RACE 报告数(否则取证没意义)")
# ── 3. skip 排除表:真跑一次坏输入,要求它必须失败 ──
#
# ★ 这里换过三版,每一版的失效方式都值得记:
# v1 `any("没有写原因" in r ...)` —— 只查提示文案;把 if 条件改 false 仍绿
# v2 剥注释后查 `${reason// /}` + `exit 1` —— 两个 token 都在仍可能不成立;
# 实测 `if [ -z "${reason// /}" ] && false; then` 依然绿
# v3 真跑、但拿真实 webui 那一行做坏输入 —— 该排除项修好被删掉时**误报红**
# v4(本条)用**合成行**注入 TABLE 再真跑:
# · 不依赖任何真实排除项 ⇒ 排除项增删不会引起误报
# · 真跑 ⇒ `&& false` 这类「形似而无效」的改动会被抓出来
# v5(ID:19)堵空表假绿:v4 的 `(.*?)` 非贪婪且允许空串 ⇒ 排除表被删到
# 空串时捕获体为空,下游只看 "$n" -eq 0 就静默走「无排除项」分支,
# 排除项漏删/误删永远不会被任何人发现。现在要求**空表必须显式声明**
# (heredoc 里保留一行 `# none`),且下列三条各自有只违反它的坏输入做自测。
#
# 两条守卫必须**各自**用只违反它的输入来验,否则一条会顶另一条的包
# (第一版我把 reason 与 ticket 同时清空,ticket 守卫兜住了,reason 守卫死没死看不出来)。
import subprocess as _sp
import tempfile as _tf
# 空表显式标记:与 ci.yml 里的判据同一串(改一处必须同时改另一处)。
_EMPTY_MARK = "# none"
_tbl = None
for _n, _r in race:
if "计算 -race 排除表并打印" in (_n or ""):
_tbl = _r
if _tbl is None:
fails.append("race job 找不到「计算 -race 排除表并打印」步骤 —— 无法自测排除表守卫")
elif "${{" in _tbl:
fails.append("排除表步骤含 GitHub 表达式,本脚本无法本地执行它(需改用别的验证方式)")
else:
import re as _re
_m = _re.search(r"read -r -d '' TABLE <<'EOF'[^\n]*\n(.*?)\nEOF\n", _tbl, _re.DOTALL)
if _m is None:
fails.append("排除表步骤里找不到 TABLE heredoc —— 无法注入合成坏行")
else:
def _run_body(body):
fd, tmp_sh = _tf.mkstemp(suffix=".sh")
with __import__("os").fdopen(fd, "w", encoding="utf-8") as f:
f.write(body)
out = _tf.mktemp()
env = dict(__import__("os").environ)
env["GITHUB_OUTPUT"] = out
try:
return _sp.run(["bash", tmp_sh], capture_output=True, text=True, env=env)
finally:
__import__("os").unlink(tmp_sh)
if __import__("os").path.exists(out):
__import__("os").unlink(out)
def _inject(line):
inner = _m.group(1)
new_inner = (line + "\n" + inner) if inner.strip() else line
return _tbl[:_m.start(1)] + new_inner + _tbl[_m.end(1):]
def _replace_table(new_inner):
"""把 heredoc 的捕获体整段替换成 new_inner(用来造各种空表形态)。"""
return _tbl[:_m.start(1)] + new_inner + _tbl[_m.end(1):]
# 正向:原样必须通过
if _run_body(_tbl).returncode != 0:
fails.append("排除表步骤用当前 ci.yml 实跑不通过(正向基线坏)")
# ── 正向:合法空表(显式 `# none` 标记)必须通过 ──
# ★ 这是"排除表可以合法为空"的必要性证明:最后一条 pkg 行被删掉后,
# 表本来就该空,不能一空就红。
if _run_body(_replace_table(_EMPTY_MARK)).returncode != 0:
fails.append("合法的空表(显式 `# none` 标记)竟被拒 —— 空表本应被允许,"
"守卫不能把「排除项删干净」与「漏写标记」混为一谈")
# ── 反向:空体没有任何标记 ⇒ 必须红(这就是 ID:19 的假绿入口)──
#
# ★ 自测自身的可被改坏性(ID:19 变异 M8/M8d 抓出来的缺口):
# 初版把四条反向样本放在一个 for 循环里。把容器改成 `[] and [...]`
# 或 `[] or [...]` 后循环空转、一条都不跑,而**没人发现**
# —— 实测那份变异下本脚本 exit=0、零失败(自测静默死掉)。
# 补一道"旁路重算 _ran"也不够:M8d 证明旁路能正常数出 4/4,
# 而真正做判断的那段已被空转(两份逻辑脱钩了)。
#
# ⇒ 结论:**不用可被改坏的容器驱动断言**。把四条显式展开,
# 样本集只当"取值源"用(下标直取),不存在"改坏 for 容器"这个面。
# 这样只剩两种可能:样本被删(len 断言报)或某条真的没咬住(逐条报)。
_EMPTY_BAD_CASES = [
("完全空体", ""),
("只有空行", "\n"),
("只有空行+空白", "\n \n"),
("非显式标记的注释", "# 无排除项"),
]
if len(_EMPTY_BAD_CASES) != 4:
fails.append("空表反向样本数变了(期望 4,实际 %d)—— 除新增样本,否则说明样本被删"
% len(_EMPTY_BAD_CASES))
_rejected_checked = [0] # 用 list 当可变计数(闭包内可写)
def _must_reject(idx):
"""断言第 idx 个空表形态**必须**被拒。"""
_rejected_checked[0] += 1
if idx >= len(_EMPTY_BAD_CASES):
# 样本被删:不抛 IndexError,给可读报错(崩溃不如可读断言好)。
fails.append("空表反向样本集缺第 %d 条(只剩 %d 条)—— 样本被删或容器被改坏"
% (idx, len(_EMPTY_BAD_CASES)))
return
_n, _i = _EMPTY_BAD_CASES[idx]
if _run_body(_replace_table(_i)).returncode == 0:
fails.append(
"空表形态「%s」竟通过 ⇒ 空表显式声明守卫已失效"
"(表被删空与有意清空无法区分,排除项漏删会静默变绿)" % _n)
_must_reject(0) # 完全空体
_must_reject(1) # 只有空行
_must_reject(2) # 只有空行+空白
_must_reject(3) # 非显式标记的注释
# ★ 四次调用本身也必须被核对(ID:19 变异 M8d):
# 把上面四行 `_must_reject(...)` 整段删掉/注掉时,前面所有断言代码都还在,
# 但**一次都不会跑** —— 自测静默死掉、门禁照绿(实测 exit=0)。
# 所以把调用次数当成一份合同:删一行也红,不会默默少检。
if _rejected_checked[0] != 4:
fails.append("空表反向自测只调用了 %d/4 次 —— 有检查被删/跳过(自测静默失效)"
% _rejected_checked[0])
_probe = "test|internal/probe/synthetic|TestSyntheticProbe"
# 只打 reason:reason 空、ticket 有效
if _run_body(_inject(_probe + "||PROBE-TICKET")).returncode == 0:
fails.append("合成行「reason 空 / ticket 有效」竟通过 ⇒ reason 守卫已失效"
"(形似而无效的改动,例如 if 条件加 && false)")
# 只打 ticket:reason 有效、ticket 空
if _run_body(_inject(_probe + "|合成探针原因|")).returncode == 0:
fails.append("合成行「reason 有效 / ticket 空」竟通过 ⇒ ticket 守卫已失效")
# ── 4. go job:测试归拢门 + 包依赖门必须都在 ──
#
# 2026-11-05:整机启动图判据(bootgraph_test.go)等白盒测试已全部删除,
# 原先「必须单列一步跑 kernelmod/all」的断言随之作废 —— 盯着一个不再
# 存在的步骤就是永远红的空转。改为盯两道新的构建期静态门。
gojob = step_runs("go")
allgo = "\n".join(r for _n, r in gojob)
check("源码目录不得有 _test.go" in allgo or
"find internal cmd pkg providers deploy -name '*_test.go'" in allgo,
"go job 缺少「测试归拢」硬门(源码目录不得有 _test.go)")
check("go list -deps" in allgo,
"go job 缺少「包依赖门」(go list -deps 属工具链,不写在 go test 里)")
# ── 5. cross job:cmd 清单必须实测枚举,不许写死 ──
cross = step_runs("cross")
pure = [r for _n, r in cross if "CGO_ENABLED=0 go build" in r and "GITHUB_OUTPUT" in r]
check(bool(pure), "cross job 缺少「枚举可纯交叉编译的 cmd」步骤")
builds = [(n, r) for n, r in cross if "go build -trimpath" in r]
check(bool(builds), "cross job 里没有真正的 go build 步骤")
for _n, r in builds:
check("steps.pure.outputs.cmds" in r,
"交叉构建必须吃枚举结果 steps.pure.outputs.cmds,不许写死 cmd 列表")
# ── 5.5 code_lines 自测:这是「注释顶包」的唯一防线,必须自己也被验 ──
#
# ★ 为什么需要这段自测(M7 的教训):
# 原先把「code_lines 不再剥注释」当成一条变异,但它**单独注入时不会红** ——
# 因为 ci.yml 完好时,`grep -v cmd/gui` 真实存在,剥不剥注释都能匹配上。
# 那是**空变异**(行为没变,不可能红),拿它当判据缺口是误判。
# 正确做法不是造更狠的变异,而是让 code_lines 的行为**自己可被验证**:
# 喂一个"cmd/gui 只出现在注释里"的样本,要求剥离后断言为假。
# 这样把 code_lines 改成不剥注释,本条立刻失败 —— 单文件、真行为变化。
_probe = "#!/bin/bash\n# cmd/gui 只是注释里的说明\necho hi\n"
if "cmd/gui" in code_lines(_probe):
fails.append("code_lines 没剥掉 shell 注释 —— cmd/gui 断言会被注释顶包(假绿)")
_keep = "#!/bin/bash\nALL=$(go list ./... | grep -v '^.*/cmd/gui$')\n"
if "cmd/gui" not in code_lines(_keep):
fails.append("code_lines 把可执行行也删了 —— 断言会假红")
# ── 6. pkg 级排除(整包不进 -race):必须带修复单号 + 兜底断言 ──
#
# 整包排除是有代价的:该包所有用例都不再受竞争检测保护。
# 因此要求:① 排除表每项必须有修复单号;② 剔除后包数有下限兜底,
# 不许"排除逻辑写坏了导致静默少测";③ 断言排除项确实从待测列表消失。
allrace = "\n".join(r for _n, r in race)
if "KERNEL-REGISTRY-SETLOCK" in allrace or "pkg" in allrace:
check("修复单" in allrace or "修复单号" in allrace,
"pkg 级排除必须写明修复单号(否则成了无主的静默掩盖)")
check("-lt 50" in allrace or "lt 50" in allrace,
"剔除后必须有包数下限兜底(防止排除逻辑写坏导致静默少测)")
check("仍出现在待测列表里" in allrace or "剔除逻辑失效" in allrace,
"必须断言被排除的包确实从列表消失(防止写了排除但没生效)")
# ── 7. 每个 job 的每个 run 步骤:bash 语法必须过 ──
import os
import re
import subprocess
import tempfile
for jn, job in jobs.items():
for st in job.get("steps") or []:
run = st.get("run")
if not run:
continue
body = re.sub(r"\$\{\{[^}]*\}\}", "PLACEHOLDER", run)
fd, p = tempfile.mkstemp(suffix=".sh")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(body)
r = subprocess.run(["bash", "-n", p], capture_output=True, text=True)
if r.returncode != 0:
first = (r.stderr or "").strip().splitlines()
fails.append("shell 语法错误 @ %s / %s: %s"
% (jn, st.get("name"), first[0] if first else "?"))
finally:
os.unlink(p)
if fails:
print("CI 门禁自检失败:")
for f in fails:
print(" x " + f)
sys.exit(1)
print("CI 门禁自检通过:动态枚举 / 排除表校验 / 测试归拢门 / 包依赖门 / 交叉枚举 / 全部 run 步骤语法")