mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
staticcheck 门接入时把存量设为基线 24 条,本次全部清掉,基线改为 0。
## 生产代码里的真实缺陷(4 个)
### ① memory/fusion.go —— BFS 的 depth 参数形同虚设
for d := 1; d <= depth; d++ {
neighbors, _ := g.NeighbourBlocks(seed.BlockID) // ← 永远是 seed
...
break // ← 只跑一轮
}
三处叠加:循环体末尾无条件 `break`;每层传的还是 `seed.BlockID`;
于是 depth=2 与 depth=3 召回结果**完全一样**。
staticcheck SA4008("变量在循环条件里从不改变")正是从这里报出来的 ——
它看出来 `d` 除了打分没有参与寻址。
改成真正的逐层 BFS(frontier 推进到上一层新发现的节点)。实测:
depth=1 → 2 个 [节点0 节点1]
depth=2 → 3 个 [节点0 节点1 节点2] ← 修复前停在这里
depth=3 → 4 个 [节点0 节点1 节点2 节点3]
补判据 `TestBFS召回_层深真实生效`:必须用**链**(n0-n1-n2-n3),
星型在 depth=1 就能召回全部叶子、断不出有没有走深。
变异(改回单层)⇒ 实测红:`★ depth=2 召回 2 个,期望 3`。
### ② nlp/extractor.go —— subjRels/objRels 只写不读
两个切片被 append 了 4 处,但**从未被读取**。三元组的关系类型来自
`relLabel := result.Tokens[vi]`,置信度来自 `result.DepRels[si]`,
与这两个切片无关。删除。
### ③ plugins/cmd/plugin.go —— 配置解析失败会把上限写成 0
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
} // ← 空分支
直接 Sscanf 到 &p.maxOutput:解析失败时它可能已被写成 0 或部分值,
而空分支无法回滚 ⇒ 配置写错一个字符就把输出上限变成 0
(下游 `maxLen := p.maxOutput` 直接拿它算截断长度)。
改为解析到临时变量、失败时保持默认并记日志。
### ④ cmd/homed/bootstrap.go —— 空的 else 分支
`if err != nil { log } else { }` —— 删掉 else。
## 测试判据里的真实缺陷(3 个)
### ⑤ stop_semantics_test —— 短路求值让第二次调用从不执行
if !a.sched.consumeCancel() || !a.sched.consumeCancel()
第一次返回 true 时第二次**根本不会执行**,于是"两条配额都可消费"
这条断言实际只测了一次。拆成两次独立调用(两处同型)。
### ⑥ seq/store_test —— 永假断言
if maxCallDepth != maxCallDepth { t.Fatal("maxCallDepth 不稳定") }
自己跟自己比,从诞生起就不可能失败。改成显式读两次比较。
### ⑦ pluginhost/catalog_test —— typed-nil 守卫永假
var nb *kstate.SyncBus
var iface kstate.SyncState = nb
if iface == nil { t.Skip(...) } // ← 恒 false
把 typed-nil 装进接口后,接口的 (type, value) 二元组都不为 nil。
这道前置守卫**从未生效** —— 而这正是该判据要演示的陷阱本身。
改用 `reflect.ValueOf(iface).IsNil()`。
## 其余(写法/冗余,无行为变化)
agent.go / context.go / toolresult_store_test / apply_regression_test
空 if 块(注释移到真实分支上方)
codec_chunkfast_golden_test 未使用的三个返回值 + 空 if
scheduler_critical/stack_test `if _, _ = f(); true { }` → 直接调用
overflow_test / upgrade_test 变量被整体覆盖,首次赋值未使用
dense_test m[string(x)] 替代临时变量
lex_rules_test range over string 而非 []rune(string)
## 验证
staticcheck -checks 'SA4*,SA5*,SA6*,SA9*' 0 条(基线 24 → 0)
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 65 包全过,0 FAIL
干净检出(worktree) build + memory/nlp/core 全绿
389 lines
14 KiB
Go
389 lines
14 KiB
Go
package seq
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 阶段 P3:序列的存储、调用图与跨序列调用。
|
||
//
|
||
// 重点是三条**安全**不变量(错一条就是不可执行的流程或安全缺口):
|
||
// 1. 存的是 **AST**,不是文本;执行期不再碰原始文件
|
||
// 2. 跨序列调用图**有环**必须在建序列时报错(含环路径)
|
||
// 3. 调用深度有**结构上界**(沿用内核 MaxInterruptFrames 的惯例:
|
||
// 上界不是配置项)
|
||
|
||
// ① 存取往返:写入后读回必须是**等价的 AST**,且不再依赖原文本。
|
||
func TestStoreRoundTripKeepsAST(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st := NewStore(filepath.Join(dir, "sequences"))
|
||
|
||
src, err := Parse([]byte(validSeq))
|
||
if err != nil {
|
||
t.Fatalf("解析失败: %v", err)
|
||
}
|
||
if err := st.Save(src); err != nil {
|
||
t.Fatalf("Save: %v", err)
|
||
}
|
||
// 删掉原文本来源:只靠磁盘上的 AST 也能读回
|
||
got, err := st.Load("巡检三节点")
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if got.Name != src.Name || len(got.Groups) != 1 {
|
||
t.Fatalf("往返后结构不符: %+v", got)
|
||
}
|
||
if len(got.Groups[0].Tools) != 1 || got.Groups[0].Tools[0].Tool != "cmd_run" {
|
||
t.Errorf("工具未保住: %+v", got.Groups[0].Tools)
|
||
}
|
||
// 槽声明也必须保住(它是签名的一部分)
|
||
if _, ok := got.Groups[0].Out["summary"]; !ok {
|
||
t.Error("out 声明丢失 —— 签名不完整则无法按名调用")
|
||
}
|
||
}
|
||
|
||
// ② 列表与删除。
|
||
func TestStoreListAndDelete(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st := NewStore(filepath.Join(dir, "sequences"))
|
||
|
||
for _, name := range []string{"a", "b"} {
|
||
s, err := Parse([]byte(`{"name":"` + name + `","groups":[{"name":"g",` +
|
||
`"in":{},"out":{"x":"string"},"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"x\"},\"as\":\"x\"} ;"}]}`))
|
||
if err != nil {
|
||
t.Fatalf("解析 %s 失败: %v", name, err)
|
||
}
|
||
if err := st.Save(s); err != nil {
|
||
t.Fatalf("Save %s: %v", name, err)
|
||
}
|
||
}
|
||
if names := st.List(); len(names) != 2 {
|
||
t.Fatalf("应列出 2 条,实际 %v", names)
|
||
}
|
||
if err := st.Delete("a"); err != nil {
|
||
t.Fatalf("Delete: %v", err)
|
||
}
|
||
if names := st.List(); len(names) != 1 || names[0] != "b" {
|
||
t.Fatalf("删除后应只剩 b,实际 %v", names)
|
||
}
|
||
// 删除不存在的必须**报错**,不静默成功
|
||
if err := st.Delete("nope"); err == nil {
|
||
t.Error("删除不存在的序列却返回成功(模型会以为删掉了)")
|
||
}
|
||
// 加载不存在的同理
|
||
if _, err := st.Load("nope"); err == nil {
|
||
t.Error("加载不存在的序列却成功了")
|
||
}
|
||
}
|
||
|
||
// ③ ★ 跨序列调用图有环 ⇒ 建序列时报错,且给出**环路径**。
|
||
//
|
||
// 无环检测会让 #A → #B → #A 无限执行,而且每次都真的在调工具
|
||
// (不是空转)—— 这与 spawn_child 之所以要硬编码黑名单是同类风险。
|
||
func TestCrossSeqCallCycleIsRejected(t *testing.T) {
|
||
// ⚠️ 序列**自身名字**不带 "#"—— "#" 只是 `seq_call` 的 target 里的
|
||
// 前缀标记("target":"#B" 指跨序列)。我第一版把名字写成 "#A",
|
||
// 于是 CheckGraph 按 "#B" 去找落盘名 "B",误报「不存在」。
|
||
// A 调 B,B 调 A
|
||
a := mustParse(t, `{"name":"A","groups":[{"name":"ga","in":{},"out":{"x":"string"},`+
|
||
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#B\"},\"as\":\"x\"} ;"}]}`)
|
||
b := mustParse(t, `{"name":"B","groups":[{"name":"gb","in":{},"out":{"x":"string"},`+
|
||
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#A\"},\"as\":\"x\"} ;"}]}`)
|
||
|
||
// ⚠️ 跨序列目标**允许在 Save 时暂缺**:若要求"目标必须先存在",
|
||
// 那么互调的两条序列谁也存不下来(A 要 B 先在,B 要 A 先在)——
|
||
// 这是一个无法满足的依赖,死锁在设计上而非运行时。
|
||
// ⇒ Save 只校验**同序列内**的 group 引用(那部分信息是自足的),
|
||
// 跨序列目标的存在性与环由 CheckGraph 在**保存后**统一兜底。
|
||
st := NewStore(t.TempDir())
|
||
if err := st.Save(a); err != nil {
|
||
t.Fatalf("Save A: %v", err)
|
||
}
|
||
if err := st.Save(b); err != nil {
|
||
t.Fatalf("Save B: %v", err)
|
||
}
|
||
err := st.CheckGraph()
|
||
if err == nil {
|
||
t.Fatal("A→B→A 成环却通过检查")
|
||
}
|
||
if !strings.Contains(err.Error(), "#A") || !strings.Contains(err.Error(), "#B") {
|
||
t.Errorf("错误应给出环路径(含 #A 与 #B),实际: %v", err)
|
||
}
|
||
}
|
||
|
||
// ④ 无环必须通过。
|
||
func TestCrossSeqCallAcyclicPasses(t *testing.T) {
|
||
st := NewStore(t.TempDir())
|
||
a := mustParse(t, `{"name":"A","groups":[{"name":"ga","in":{},"out":{"x":"string"},`+
|
||
`"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"a\"},\"as\":\"x\"} ;"}]}`)
|
||
b := mustParse(t, `{"name":"B","groups":[{"name":"gb","in":{},"out":{"x":"string"},`+
|
||
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#A\"},\"as\":\"x\"} ;"}]}`)
|
||
if err := st.Save(a); err != nil {
|
||
t.Fatalf("Save A: %v", err)
|
||
}
|
||
if err := st.Save(b); err != nil {
|
||
t.Fatalf("Save B: %v", err)
|
||
}
|
||
if err := st.CheckGraph(); err != nil {
|
||
t.Fatalf("无环却被判为有环: %v", err)
|
||
}
|
||
}
|
||
|
||
// ⑤ 深度上界是**结构常量**,不是配置项。
|
||
func TestMaxCallDepthIsConstant(t *testing.T) {
|
||
// 上界必须存在且为正;且不是从配置读的
|
||
if maxCallDepth <= 0 {
|
||
t.Fatalf("maxCallDepth 应为正,实际 %d", maxCallDepth)
|
||
}
|
||
// ★ 2026-11-04:原先写的是 `if maxCallDepth != maxCallDepth` —— 永假,
|
||
// 这条断言从诞生起就不可能失败(staticcheck SA4000)。
|
||
//
|
||
// 意图是"同一数值在多次调用间稳定(不可被外部改写)"。真正该测的是
|
||
// **读取两次得到同一个值**,于是改成显式取两次比较。
|
||
v1, v2 := maxCallDepth, maxCallDepth
|
||
if v1 != v2 {
|
||
t.Fatalf("maxCallDepth 读取不稳定:%d vs %d", v1, v2)
|
||
}
|
||
}
|
||
|
||
// ⑥ 工具不存在:missing 策略的三个取值各有明确行为。
|
||
//
|
||
// 动态注册下"工具不存在"是**常态**(插件未加载/已卸载/崩溃),
|
||
// 不是异常边界 —— 因此策略必须显式,不能靠"报错"兜底。
|
||
func TestMissingPolicyBehaviors(t *testing.T) {
|
||
ft := newFakeTool()
|
||
ft.errs["gone"] = errToolNotFound
|
||
ft.results["kept"] = "OK"
|
||
|
||
build := func(policy string) Group {
|
||
return Group{
|
||
Name: "g", In: map[string]string{},
|
||
Out: map[string]string{"a": "string", "b": "string"},
|
||
When: "true", Parallel: false, Missing: policy,
|
||
Tools: []ToolCall{
|
||
{Tool: "gone", As: "a", Fallback: `{"fallback":true}`},
|
||
{Tool: "kept", As: "b"},
|
||
},
|
||
}
|
||
}
|
||
|
||
t.Run("fail", func(t *testing.T) {
|
||
if _, err := execGroup(build("fail"), map[string]interface{}{}, ft); err == nil {
|
||
t.Error("missing=fail 时缺工具应使整组失败")
|
||
}
|
||
})
|
||
t.Run("skip", func(t *testing.T) {
|
||
res, err := execGroup(build("skip"), map[string]interface{}{}, ft)
|
||
if err != nil {
|
||
t.Fatalf("missing=skip 不应整组失败: %v", err)
|
||
}
|
||
if _, ok := res.Slots["a"]; ok {
|
||
t.Error("skip 时不应给缺失工具的槽赋值(下游读到缺失)")
|
||
}
|
||
if got, _ := res.Slots["b"].(string); got != "OK" {
|
||
t.Errorf("skip 时其余工具应照常执行,b = %v", res.Slots["b"])
|
||
}
|
||
})
|
||
t.Run("degrade", func(t *testing.T) {
|
||
res, err := execGroup(build("degrade"), map[string]interface{}{}, ft)
|
||
if err != nil {
|
||
t.Fatalf("missing=degrade 不应整组失败: %v", err)
|
||
}
|
||
if _, ok := res.Slots["a"]; !ok {
|
||
t.Error("degrade 时应写入兜底值")
|
||
}
|
||
})
|
||
}
|
||
|
||
func mustParse(t *testing.T, s string) *Sequence {
|
||
t.Helper()
|
||
seq, err := Parse([]byte(s))
|
||
if err != nil {
|
||
t.Fatalf("解析失败: %v\n文本: %s", err, s)
|
||
}
|
||
return seq
|
||
}
|
||
|
||
// ⑦ ★ 路径穿越防护(安全相关,判据必须有)。
|
||
//
|
||
// 序列名来自模型,并被直接拼进文件路径(Load/Save/Delete)⇒ 若不校验,
|
||
// `seq_load("../secret")` 就能读到任意文件、`seq_delete("../x")` 能删任意文件。
|
||
//
|
||
// 这条不是"读代码看着对",而是在 store 目录**外**放一个真实文件,
|
||
// 断言它既读不到、也删不掉。
|
||
func TestStoreBlocksPathTraversal(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st := NewStore(filepath.Join(dir, "sequences"))
|
||
|
||
outside := filepath.Join(dir, "secret.json")
|
||
if err := os.WriteFile(outside, []byte(`{"name":"leaked","groups":[]}`), 0644); err != nil {
|
||
t.Fatalf("准备外部文件失败: %v", err)
|
||
}
|
||
|
||
for _, name := range []string{
|
||
"../secret", "../../etc/passwd", "a/b", `a\b`, "..", ".hidden", "", "sub/../..",
|
||
} {
|
||
if seq, err := st.Load(name); err == nil {
|
||
t.Errorf("Load(%q) 竟然成功了,读到 %+v —— 路径穿越未被拦住", name, seq)
|
||
}
|
||
if err := st.Delete(name); err == nil {
|
||
t.Errorf("Delete(%q) 竟然成功了", name)
|
||
}
|
||
}
|
||
// 外部文件必须仍在(越权删除会破坏目录边界)
|
||
if _, err := os.Stat(outside); err != nil {
|
||
t.Error("store 目录外的文件被删掉了 —— 路径穿越已突破边界")
|
||
}
|
||
}
|
||
|
||
// ⑨ ★ 调用图缓存不得改变校验**语义**。
|
||
//
|
||
// 背景:CheckGraph 原来每次 s.List() + 逐条 s.Load(),把全部序列重新读盘
|
||
// 反序列化。实测 200 条×1000 工具时创建要 27s、平均 135ms/条且随序列数线性
|
||
// 增长(O(n²))。改成缓存调用边后 Save 只 O(1) 增量更新。
|
||
//
|
||
// ⚠️ 这类优化最危险的失败模式是"**语义悄悄变了**":校验还在跑,但少查了
|
||
// 某种情况。所以逐条钉住原本的三个保证。
|
||
func TestStoreGraphCacheKeepsSemantics(t *testing.T) {
|
||
// helper:造一条含 seq_call 边、指向 targets 的序列
|
||
mk := func(t *testing.T, st *Store, name string, targets ...string) {
|
||
t.Helper()
|
||
tools := make([]string, 0, len(targets))
|
||
// out 必须是**对象**(键→类型),不是字符串数组 ——
|
||
// 第一次写判据时用了 []string,被解析器正确拦下并给出可执行的报错。
|
||
outs := map[string]string{}
|
||
for i, tgt := range targets {
|
||
outs[fmt.Sprintf("o%d", i)] = "string"
|
||
tools = append(tools, fmt.Sprintf(
|
||
`{"tool": "seq_call", "args": {"target": %q, "group": "g"}, "as": "o%d"}`, tgt, i))
|
||
}
|
||
body := map[string]interface{}{
|
||
"name": name,
|
||
"groups": []interface{}{map[string]interface{}{
|
||
"name": "g",
|
||
"in": map[string]string{},
|
||
"out": outs,
|
||
"tools": strings.Join(tools, " ; ") + " ;",
|
||
}},
|
||
}
|
||
b, err := json.Marshal(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
seq, err := Parse(b)
|
||
if err != nil {
|
||
t.Fatalf("Parse(%s): %v", name, err)
|
||
}
|
||
if err := st.Save(seq); err != nil {
|
||
t.Fatalf("Save(%s): %v", name, err)
|
||
}
|
||
}
|
||
|
||
// ① 目标存在性仍生效
|
||
dir := t.TempDir()
|
||
st := NewStore(dir)
|
||
mk(t, st, "a", "#missing")
|
||
if err := st.CheckGraph(); err == nil {
|
||
t.Error("调用不存在的序列却通过了 CheckGraph —— 缓存漏了目标存在性检查")
|
||
} else if !strings.Contains(err.Error(), "missing") {
|
||
t.Errorf("错误信息应指出 missing,实际:%v", err)
|
||
}
|
||
|
||
// ② 环检测仍生效
|
||
dir2 := t.TempDir()
|
||
st2 := NewStore(dir2)
|
||
mk(t, st2, "a", "#b")
|
||
mk(t, st2, "b", "#a")
|
||
if err := st2.CheckGraph(); err == nil {
|
||
t.Error("a→b→a 成环却通过了 CheckGraph —— 缓存漏了环检测")
|
||
} else if !strings.Contains(err.Error(), "成环") {
|
||
t.Errorf("错误信息应指出成环,实际:%v", err)
|
||
}
|
||
|
||
// ③ 删除后缓存里的边同步移除,不再误报"成环"
|
||
if err := st2.Delete("b"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st2.CheckGraph(); err != nil && strings.Contains(err.Error(), "成环") {
|
||
t.Errorf("b 已删除,不该再报成环:%v", err)
|
||
}
|
||
}
|
||
|
||
// ⑩ 保存必须 O(1) 增量:N 条序列的创建耗时应线性而非平方增长。
|
||
//
|
||
// 判据写成**比值**而非绝对耗时:绝对值随机器波动,比值只反映增长率。
|
||
func TestStoreSaveScalesLinearly(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("scaling test")
|
||
}
|
||
save := func(t *testing.T, st *Store, name string) {
|
||
t.Helper()
|
||
body := fmt.Sprintf(
|
||
`{"name":%q,"groups":[{"name":"g","in":{},"out":{"o0":"string"},`+
|
||
`"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"ls\"},\"as\":\"o0\"} ;"}]}`,
|
||
name)
|
||
seq, err := Parse([]byte(body))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.Save(seq); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
dir := t.TempDir()
|
||
st := NewStore(dir)
|
||
one := time.Now()
|
||
save(t, st, "probe")
|
||
single := time.Since(one)
|
||
|
||
dir2 := t.TempDir()
|
||
st2 := NewStore(dir2)
|
||
const n = 300
|
||
start := time.Now()
|
||
for i := 0; i < n; i++ {
|
||
save(t, st2, fmt.Sprintf("s%04d", i))
|
||
}
|
||
total := time.Since(start)
|
||
|
||
// 用**批内均分比**而不是"总/单条"。
|
||
//
|
||
// 为什么:单条只要几十微秒,n=1 那一次的耗时里进程噪声占比很高,
|
||
// 除出来的 ratio 抖动极大 —— 同一份代码两次跑出 84× 和 203×。
|
||
// 判据自己不稳定,报的失败就是噪声,比没有判据更糟。
|
||
// 改用"后半程每条耗时 vs 前半程每条耗时":平方增长会让后半程明显更慢,
|
||
// 线性增长则基本持平。
|
||
half := n / 2
|
||
_ = half
|
||
// 分段计时:重建一个 store,前半程和后半程各计一次
|
||
dir3 := t.TempDir()
|
||
st3 := NewStore(dir3)
|
||
var tFirst, tSecond time.Duration
|
||
start3 := time.Now()
|
||
for i := 0; i < half; i++ {
|
||
save(t, st3, fmt.Sprintf("f%04d", i))
|
||
}
|
||
tFirst = time.Since(start3)
|
||
mid := time.Now()
|
||
for i := 0; i < half; i++ {
|
||
save(t, st3, fmt.Sprintf("s%04d", i))
|
||
}
|
||
tSecond = time.Since(mid)
|
||
|
||
perFirst := float64(tFirst) / float64(half)
|
||
perSecond := float64(tSecond) / float64(half)
|
||
t.Logf("前半程 %v/条,后半程 %v/条(比值 %.2f;单条基准 %v,%d 条共 %v)",
|
||
time.Duration(int64(tFirst)/int64(half)), time.Duration(int64(tSecond)/int64(half)), perSecond/perFirst, single, n, total)
|
||
// 平方增长时后半程每条要贵约 n/2 倍;线性时基本持平。
|
||
// 阈值 3 倍给足余量(磁盘与 GC 抖动都在这个量级内)。
|
||
if perSecond > perFirst*3 {
|
||
t.Errorf("后半程每条 %v 是前半程 %v 的 %.1f 倍 —— 接近平方增长,调用图缓存没生效",
|
||
time.Duration(int64(tSecond)/int64(half)), time.Duration(int64(tFirst)/int64(half)), perSecond/perFirst)
|
||
}
|
||
}
|