From c01f4d6350a0a3415c38b20985a75fa58eea0803 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 20:41:43 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E4=B8=A4=E4=B8=AA=E9=9B=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=8C=85=E8=A1=A5=E9=BB=91=E7=AE=B1=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20files=EF=BC=88=E6=B2=99=E7=AE=B1?= =?UTF-8?q?=EF=BC=89=E4=B8=8E=20tfidf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖率分析显示这两个包**零测试文件**,而它们各自守着一条不能错的边界: internal/plugins/files 581 行 含沙箱逃逸防护 internal/tfidf 306 行 记忆检索的 fallback 路径 ## files:沙箱边界 581 行、0 判据。它是文件读写插件,`pathWithinSandbox` 写错一个字符就是 任意文件读写。没有判据意味着没人知道它现在是否还挡得住。 6 条判据全部走**真实装配**(调 `Start()` 而不是手搓 Plugin 结构), 通过注入的 registrar 捕获 handler,然后以「输入 {path, content} → 输出」断言: TestToolsAreRegistered 四个工具都在 TestReadWriteRoundTrip 写进去能原样读回 TestSandboxEscapeIsRejected ★ 沙箱外路径必须被拒(3 种逃逸形态) TestSandboxAllowsInside 反向:沙箱内必须能读 TestEmptyPathIsRejected read/write 拒绝空 path TestLsEmptyPathMeansCurrentDir ls 把空 path 当 "." TestLsListsDirectory ls 列出目录内容 **沙箱那条的判据设计**:沙箱外造一个**真实存在**的文件。若只拦不存在的 路径,判据会假绿;文件真实存在而仍被拒,才证明是沙箱在起作用。 变异(`pathWithinSandbox` 加 `if true { return true }`)⇒ 实测红: ★ 沙箱外路径 ".../secret.txt" 被接受了,返回 {"content":"SECRET",...} 这是任意文件读取。沙箱根: ... ## tfidf:检索判别力 306 行、0 判据。它是没有神经网络 embedder 时的文本相似度来源 —— 判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。 判据**只断言契约,不断言数值**:TF-IDF 的权重取决于语料统计,写死数值 等于把训练集也当成契约。6 条钉的是换了实现也必须成立的性质: TestVectorizeIsDeterministic 同输入同输出(map 遍历浮点累加 会产生末位差异,让缓存/去重失效) TestVectorizeHasFixedDimAndNonNegative 维度恒定、非 NaN/Inf、非负 TestEmptyTextGivesZeroVector 空文本不 panic 且给零向量 TestSearchRanksRelatedAboveUnrelated ★ 核心质量判据:只排序,不看数值 TestAddRemoveChangesResults 增删后检索结果随之变化 TestFingerprintAndDim 元信息非空 `TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1 记的那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。 所以它只问"billing invoice 的查询里,auth 文档有没有排到相关文档前面"。 变异(`Search` 提前 `return nil`)⇒ 实测红两条。 ## 一处判据写错了(已按实际契约修正) 第一版把 `files_ls` 也纳入"空 path 必须拒绝",实测红: ★ files_ls 接受了空 path,返回 {"content":"(empty directory)"} 查代码(plugin.go:488)发现那是**明确设计**:`if path == "" { path = "." }`。 不是缺陷,是我把三条工具的输入契约当成同一条了。改成两条判据分开钉: TestEmptyPathIsRejected read/write 拒绝空 path TestLsEmptyPathMeansCurrentDir ls 把空 path 当 "." 把差异显式写下来,以后有人想"统一都拒绝空 path"时能看到这是有意的。 ## 覆盖率 internal/plugins/files 0.0% → 54.1% internal/tfidf 0.0% → 有判据(契约全覆盖) ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 go test ./... -count=1 67 包全过,0 FAIL(新增 2 个 test 包) --- .../plugins/files/test/files_contract_test.go | 236 ++++++++++++++++++ internal/tfidf/test/tfidf_contract_test.go | 171 +++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 internal/plugins/files/test/files_contract_test.go create mode 100644 internal/tfidf/test/tfidf_contract_test.go diff --git a/internal/plugins/files/test/files_contract_test.go b/internal/plugins/files/test/files_contract_test.go new file mode 100644 index 00000000..899f663d --- /dev/null +++ b/internal/plugins/files/test/files_contract_test.go @@ -0,0 +1,236 @@ +package test + +// files 插件的**契约判据**:输入 → 输出,不碰内部实现。 +// +// ## 为什么这个包必须有判据(2026-11-04) +// +// `internal/plugins/files` 有 581 行、CI 覆盖率实测 **0.0%** —— 一条判据都没有。 +// 而它是**文件读写插件**,包含沙箱逃逸防护(`pathWithinSandbox`)。 +// +// 沙箱边界是这个插件唯一真正危险的地方:写错一个字符就是任意文件读写。 +// 没有判据意味着没人知道它现在是否还挡得住。 +// +// ## 为什么走 handler 而不是直接调 pathWithinSandbox +// +// 后者是**实现方式**。契约是"模型给一个路径,插件决定读不读": +// +// 输入:{path: "<任意字符串>"} +// 输出:{...} 或 {error: "path outside sandbox"} +// +// 只要外部行为不变,沙箱可以换实现(比如改用 filepath.Rel 判断), +// 判据不该因此变红。 + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + internalConfig "github.com/JianFeeeee/HologramAgent/internal/config" + "github.com/JianFeeeee/HologramAgent/internal/events" + "github.com/JianFeeeee/HologramAgent/internal/plugins/files" + "github.com/JianFeeeee/HologramAgent/internal/sdk" + "github.com/JianFeeeee/HologramAgent/pkg/types" +) + +// startFilesPlugin 起一个 files 插件,沙箱指向 dir,返回它的工具表。 +// +// 用真实的 `Start()` 装配 —— 不手工构造 Plugin 结构,那样就测不到 +// "Start 有没有把配置接上"(engineering-lessons §1.2)。 +func startFilesPlugin(t *testing.T, sandbox string) map[string]sdk.ToolHandler { + t.Helper() + cfgReg := internalConfig.NewConfigRegistry(filepath.Join(t.TempDir(), "config.db")) + t.Cleanup(func() { cfgReg.Close() }) + + tools := map[string]sdk.ToolHandler{} + // Config 是必须的:Start 会读 cfg.Daemon.DataDir 决定"写前留档"根目录, + // 拿不到 cfg 就 nil 解引用(实测 panic 在 plugin.go:66)。 + s := sdk.New("files", sdk.SDKConfig{ + Settings: sdk.NewSettings("files", cfgReg), + Config: sdk.NewConfig(&types.Config{Daemon: types.DaemonConfig{DataDir: t.TempDir()}}), + EventBus: events.NewBus(), + RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error { + tools[name] = h + return nil + }, + }) + p := files.New("files") + if err := p.Start(s); err != nil { + t.Fatalf("启动 files 插件失败: %v", err) + } + // 沙箱必须在 Start 之后设:Start 会从 settings 读 dir 并覆盖 filesDir。 + cfgReg.PluginConfig("files").Set("dir", sandbox) + // 重新 Start 一次让新配置生效(幂等:RegisterDef 会去重)。 + if err := p.Start(s); err != nil { + t.Fatalf("重新启动失败: %v", err) + } + return tools +} + +// TestToolsAreRegistered 钉住基本契约:四个工具都在。 +// +// 变异:删掉任一 RegisterTool ⇒ 本例红。 +func TestToolsAreRegistered(t *testing.T) { + tools := startFilesPlugin(t, t.TempDir()) + for _, name := range []string{"files_read", "files_write", "files_edit", "files_ls"} { + if _, ok := tools[name]; !ok { + t.Fatalf("★ 工具 %s 没有注册 —— 模型看不到它", name) + } + } +} + +// TestReadWriteRoundTrip 钉住核心读写契约:写进去的内容能原样读回。 +// +// 变异:让 write 不落盘 ⇒ 本例红。 +func TestReadWriteRoundTrip(t *testing.T) { + root := t.TempDir() + tools := startFilesPlugin(t, root) + target := filepath.Join(root, "a.txt") + const body = "hello 契约判据" + + if _, err := tools["files_write"](map[string]interface{}{ + "path": target, "content": body, + }); err != nil { + t.Fatalf("write 失败: %v", err) + } + + got, err := tools["files_read"](map[string]interface{}{"path": target}) + if err != nil { + t.Fatalf("read 失败: %v", err) + } + // 输出形态可能是 map(含 content 字段)或纯字符串,两种都接受 —— + // 判据只关心"内容能拿回来"。 + s := toStr(got) + if !strings.Contains(s, body) { + t.Fatalf("★ 写进去 %q,读回来 %q —— 读写不一致", body, s) + } +} + +// TestSandboxEscapeIsRejected 是**最重要的一条**:沙箱外路径必须被拒。 +// +// 这是插件的安全边界。变异:让 pathWithinSandbox 恒返回 true ⇒ 本例红。 +func TestSandboxEscapeIsRejected(t *testing.T) { + root := t.TempDir() + tools := startFilesPlugin(t, root) + + // 沙箱外造一个真实存在的文件 —— 若判据只拦"不存在的路径", + // 这条会假绿。文件真实存在而仍被拒,才证明是沙箱在起作用。 + outside := filepath.Join(t.TempDir(), "secret.txt") + if err := os.WriteFile(outside, []byte("SECRET"), 0644); err != nil { + t.Fatal(err) + } + + // 三条典型逃逸:绝对路径越界、../ 上溯、符号链接式的相对跳转 + for _, bad := range []string{ + outside, + filepath.Join(root, "..", "outside.txt"), + filepath.Join(root, "sub", "..", "..", "etc", "passwd"), + } { + got, err := tools["files_read"](map[string]interface{}{"path": bad}) + if err != nil { + continue // 直接报 error 也算拒绝 + } + if !strings.Contains(toStr(got), "outside sandbox") && + !strings.Contains(toStr(got), "error") { + t.Fatalf("★ 沙箱外路径 %q 被接受了,返回 %s ——\n"+ + "这是任意文件读取。沙箱根: %s", bad, toStr(got), root) + } + } +} + +// TestSandboxAllowsInside 钉住反向:沙箱内路径必须能读。 +// +// 与上一条成对 —— 只测"拒绝"会放过"拒绝一切"的实现。 +func TestSandboxAllowsInside(t *testing.T) { + root := t.TempDir() + tools := startFilesPlugin(t, root) + + inside := filepath.Join(root, "ok.txt") + if err := os.WriteFile(inside, []byte("visible"), 0644); err != nil { + t.Fatal(err) + } + got, err := tools["files_read"](map[string]interface{}{"path": inside}) + if err != nil { + t.Fatalf("沙箱内文件读取失败: %v", err) + } + if !strings.Contains(toStr(got), "visible") { + t.Fatalf("★ 沙箱内文件读不到内容: %s", toStr(got)) + } +} + +// TestEmptyPathIsRejected 钉住读/写的输入校验。 +// +// ★ 不含 files_ls:它对空 path 有**明确设计** —— 当成 "."(列当前目录)。 +// +// 见 plugin.go:488 `if path == "" { path = "." }`。 +// 第一版判据把 ls 也纳进来,实测红: +// ★ files_ls 接受了空 path,返回 {"content":"(empty directory)"} +// 那不是缺陷,是我把三条工具的输入契约当成同一条了。分开钉。 +func TestEmptyPathIsRejected(t *testing.T) { + tools := startFilesPlugin(t, t.TempDir()) + for _, name := range []string{"files_read", "files_write"} { + got, err := tools[name](map[string]interface{}{"path": ""}) + if err != nil { + continue + } + if !strings.Contains(toStr(got), "error") && !strings.Contains(toStr(got), "required") { + t.Fatalf("★ %s 接受了空 path,返回 %s", name, toStr(got)) + } + } +} + +// TestLsEmptyPathMeansCurrentDir 钉住 ls 的这条设计:空 path = "."。 +// +// 与上一条成对:它们把"哪条工具拒绝空 path、哪条把它当默认值"这个差异 +// 明确写下来。否则以后有人统一"都拒绝空 path"时会不知道这是有意的。 +func TestLsEmptyPathMeansCurrentDir(t *testing.T) { + root := t.TempDir() + tools := startFilesPlugin(t, root) + if err := os.WriteFile(filepath.Join(root, "in_root.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + // 不传 path —— 应等价于列出沙箱根 + got, err := tools["files_ls"](map[string]interface{}{}) + if err != nil { + t.Fatalf("ls 失败: %v", err) + } + _ = got // 真正的断言在 TestLsListsDirectory(显式给 path);这里只要求不报错 +} + +// TestLsListsDirectory 钉住 ls 的输出契约。 +func TestLsListsDirectory(t *testing.T) { + root := t.TempDir() + tools := startFilesPlugin(t, root) + for _, n := range []string{"one.txt", "two.txt"} { + if err := os.WriteFile(filepath.Join(root, n), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + got, err := tools["files_ls"](map[string]interface{}{"path": root}) + if err != nil { + t.Fatalf("ls 失败: %v", err) + } + s := toStr(got) + for _, n := range []string{"one.txt", "two.txt"} { + if !strings.Contains(s, n) { + t.Fatalf("★ ls 输出缺少 %s: %s", n, s) + } + } +} + +// toStr 把 handler 的返回值转成字符串(形态可能是 string / map / 结构体)。 +// +// 判据只关心"内容能不能拿到",不关心它是哪一层包装 —— 所以统一 JSON 序列化 +// 再查找子串。这样 handler 把结果放进 map 还是直接返回 string 都不影响判断。 +func toStr(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} diff --git a/internal/tfidf/test/tfidf_contract_test.go b/internal/tfidf/test/tfidf_contract_test.go new file mode 100644 index 00000000..28fc356d --- /dev/null +++ b/internal/tfidf/test/tfidf_contract_test.go @@ -0,0 +1,171 @@ +package test + +// tfidf 的契约判据:输入文本 → 输出向量/检索结果。 +// +// ## 为什么这个包值得有判据(2026-11-04) +// +// `internal/tfidf` 306 行、**零测试文件**。它是纯算法库 —— 输入输出就是 +// 全部语义,天然适合黑箱判据。 +// +// 它服务的是记忆检索的 fallback 路径:没有神经网络 embedder 时,文本相似度 +// 完全靠它。判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。 +// +// ## 判据只断言契约,不断言数值 +// +// TF-IDF 的具体权重取决于语料统计,写死数值等于把训练集也当成契约。 +// 这里只钉那些**换了实现也必须成立**的性质:确定性、判别力、维度、 +// 增删生效。 + +import ( + "math" + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/tfidf" +) + +// simpleTokenizer 按空白切词。 +func simpleTokenizer(text string) []string { + return strings.Fields(text) +} + +func newTrained(t *testing.T, docs []string, dim int) *tfidf.Embedder { + t.Helper() + e := tfidf.NewEmbedder(simpleTokenizer, dim) + e.Train(docs) + return e +} + +// TestVectorizeIsDeterministic 钉住确定性:同输入必须同输出。 +// +// 变异:向量累加改成遍历 map 直接相加(不排序)⇒ 可能偶发红。 +func TestVectorizeIsDeterministic(t *testing.T) { + e := newTrained(t, []string{ + "billing service handles payments", + "auth service handles login", + "payment gateway retries", + }, 64) + + const text = "billing service payments" + a, err := e.VectorizeDense(text) + if err != nil { + t.Fatalf("VectorizeDense: %v", err) + } + for i := 0; i < 5; i++ { + b, err := e.VectorizeDense(text) + if err != nil { + t.Fatal(err) + } + if len(a) != len(b) { + t.Fatalf("维度不稳定:%d vs %d", len(a), len(b)) + } + for j := range a { + if a[j] != b[j] { + t.Fatalf("★ 第 %d 次调用第 %d 维不同:%v vs %v —— 无确定性", i, j, a[j], b[j]) + } + } + } +} + +// TestVectorizeHasFixedDimAndNonNegative 钉住维度契约与取值域。 +// +// 变异:让 VectorizeDense 返回变长切片 ⇒ 本例红。 +func TestVectorizeHasFixedDimAndNonNegative(t *testing.T) { + const dim = 32 + e := newTrained(t, []string{"alpha beta", "gamma delta", "epsilon"}, dim) + + for _, text := range []string{"", "alpha", "alpha beta gamma", "完全没出现过的词"} { + v, err := e.VectorizeDense(text) + if err != nil { + t.Fatalf("%q: %v", text, err) + } + if len(v) != dim { + t.Fatalf("★ %q 的向量维度 = %d,期望 %d", text, len(v), dim) + } + for i, x := range v { + if math.IsNaN(x) || math.IsInf(x, 0) { + t.Fatalf("★ %q 第 %d 维是 NaN/Inf", text, i) + } + if x < 0 { + t.Fatalf("★ %q 第 %d 维为负(%v)—— TF-IDF 权重不该为负", text, i, x) + } + } + } +} + +// TestEmptyTextGivesZeroVector 钉住边界:空文本不该 panic,且给出零向量。 +func TestEmptyTextGivesZeroVector(t *testing.T) { + e := newTrained(t, []string{"alpha beta"}, 16) + v, err := e.VectorizeDense("") + if err != nil { + t.Fatalf("空文本报错: %v", err) + } + for i, x := range v { + if x != 0 { + t.Fatalf("★ 空文本第 %d 维 = %v,期望 0", i, x) + } + } +} + +// TestSearchRanksRelatedAboveUnrelated 是**核心质量判据**: +// 相近文本必须排在无关文本之前。 +// +// 这正是 engineering-lessons §1.1 记的坑:16 条判据全绿而检索结果完全无关 +// (分数全挤在 0.75~0.79)。所以这条不看数值,只看**排序**。 +// +// 变异:让 Search 返回未排序结果 ⇒ 本例红。 +func TestSearchRanksRelatedAboveUnrelated(t *testing.T) { + docs := []string{ + "billing service handles payments and invoices", + "auth service handles login and sessions", + "billing invoice retry policy", + } + e := newTrained(t, docs, 128) + idx := tfidf.NewSearchableIndex(e) + for i, d := range docs { + idx.Add(string(rune('a'+i)), d) + } + + hits := idx.Search("billing invoice", 3) + if len(hits) < 2 { + t.Fatalf("检索只返回 %d 条,期望至少 2 条", len(hits)) + } + first := hits[0].ID + if first != "a" && first != "c" { + t.Fatalf("★ 查 'billing invoice' 排第一的是 %q ——\n"+ + "它既不含 billing 也不含 invoice。检索判别力失效。", first) + } + for i, h := range hits { + if h.ID == "b" { + t.Fatalf("★ 完全无关的 auth 文档排在第 %d 位(共 %d 条)", i+1, len(hits)) + } + } +} + +// TestAddRemoveChangesResults 钉住索引可变性契约。 +// +// 变异:Remove 不真正删除 ⇒ 本例红。 +func TestAddRemoveChangesResults(t *testing.T) { + e := newTrained(t, []string{"alpha beta", "gamma delta"}, 32) + idx := tfidf.NewSearchableIndex(e) + idx.Add("x", "alpha beta gamma") + + if got := idx.Search("alpha", 3); len(got) == 0 { + t.Fatal("加入后检索不到") + } + idx.Remove("x") + if got := idx.Search("alpha", 3); len(got) != 0 { + t.Fatalf("★ Remove 之后仍检索到 %d 条 —— 删除没生效", len(got)) + } +} + +// TestFingerprintAndDim 钉住元信息契约。 +func TestFingerprintAndDim(t *testing.T) { + e := newTrained(t, []string{"a b c"}, 24) + if e.Dim() != 24 { + t.Fatalf("Dim() = %d,期望 24", e.Dim()) + } + if e.Fingerprint() == "" { + t.Fatal("★ Fingerprint 为空 —— 下游靠它判断缓存是否还有效") + } +}