mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 12:17:36 +00:00
## 用户裁定(本次唯一方向)
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 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
314 lines
16 KiB
Python
Executable File
314 lines
16 KiB
Python
Executable File
#!/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 步骤语法")
|