From 1d7c330cfce1e57088432ee2e1a5901fa08f215e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 15:01:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(hmapdev):=20mocksdk=20=E8=A1=A5=E9=BD=90=20?= =?UTF-8?q?ToolDef=20=E5=A3=B0=E6=98=8E=E9=A1=B9=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=AD=97=E6=AE=B5/=E7=B1=BB=E5=9E=8B=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 mocksdk(yaegi 解释执行时的替身 SDK)的 ToolDef 只有 6 个字段, 公共 SDK 已有 10 个 —— 缺 RecallPolicy / ParallelSafe / Serial。 ★ 危害不在编译期,而在**调试期**:插件作者用 hmapdev 在本地解释执行时, 写了 ParallelSafe:true 照常跑、不报错;直到编译安装后才发现声明根本没 被内核读到。这类不一致不会让任何现有测试失败。 这正是"黑名单不会自动跟上新执行能力"的又一次复现:主 SDK 每加一个声明项, 替身不会自动跟上。 ## 修复 ① mocksdk/plugin.go 补齐 RecallPolicy / ParallelSafe / Serial 三个字段, 注释写明"主 SDK 先加、mocksdk 没跟上"这段历史,避免后人再漂移。 ② 新增 yaegi/mocksdk/parity_test.go: - TestMockSDKToolDefMatchesSDK 比对**字段名**集合,双向都查 (少字段 = 声明静默失效;多字段 = 替身比本体还多,必有一方理解错了) - TestMockSDKToolDefTypesMatch 比对**字段类型**,逐个断言 ★ 归一化必须抹掉所有空白:gofmt 打印 "interface{}" 而反射给 "interface {}",这是打印格式差异。我第一版没抹空白, 结果每个复合类型都被误报成"类型不符" —— 判据自己制造假警报。 判据放在 tools/hmapdev 模块内(它是独立 module,主模块不包含它), 跑法:cd tools/hmapdev && go test ./yaegi/mocksdk/ --- tools/hmapdev/yaegi/mocksdk/parity_test.go | 150 +++++++++++++++++++++ tools/hmapdev/yaegi/mocksdk/plugin.go | 9 ++ 2 files changed, 159 insertions(+) create mode 100644 tools/hmapdev/yaegi/mocksdk/parity_test.go diff --git a/tools/hmapdev/yaegi/mocksdk/parity_test.go b/tools/hmapdev/yaegi/mocksdk/parity_test.go new file mode 100644 index 0000000..133d88c --- /dev/null +++ b/tools/hmapdev/yaegi/mocksdk/parity_test.go @@ -0,0 +1,150 @@ +package mocksdk + +import ( + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "reflect" + "strings" + "testing" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// structFields 解析 file 中的指定 struct,返回其字段名集合。 +func structFields(t *testing.T, file, structName string) map[string]bool { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("解析 %s 失败: %v", file, err) + } + return fieldsOf(t, fset, f, structName) +} + +func fieldsOf(t *testing.T, fset *token.FileSet, f *ast.File, structName string) map[string]bool { + t.Helper() + out := map[string]bool{} + ast.Inspect(f, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || ts.Name.Name != structName { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return false + } + for _, fld := range st.Fields.List { + for _, nm := range fld.Names { + out[nm.Name] = true + } + } + return false + }) + return out +} + +// sdkFieldsOf 用反射取公共 SDK ToolDef 的字段名。 +func sdkFieldsOf(t *testing.T) map[string]bool { + t.Helper() + out := map[string]bool{} + rt := reflect.TypeOf(sdk.ToolDef{}) + for i := 0; i < rt.NumField(); i++ { + out[rt.Field(i).Name] = true + } + return out +} + +func os_ReadFile(name string) ([]byte, error) { return os.ReadFile(name) } + +func exprString(fset *token.FileSet, e ast.Expr) string { + var sb strings.Builder + if err := printer.Fprint(&sb, fset, e); err != nil { + return "" + } + return sb.String() +} + +// normalize 归一化类型串:抹掉包路径与指针/切片等修饰差异,只比基础类型。 +func normalize(s string) string { + // 抹掉所有空白:gofmt 打印 "interface{}" 而反射给 "interface {}", + // 这是**打印格式差异**,不是类型差异 —— 我第一版没抹空白, + // 结果判据把每个复合类型都误报成"类型不符"。 + s = strings.Join(strings.Fields(s), "") + if i := strings.LastIndex(s, "."); i >= 0 && !strings.Contains(s, "]") { + s = s[i+1:] + } + return strings.TrimPrefix(s, "*") +} + +var _ = fmt.Sprintf + +// TestMockSDKToolDefMatchesSDK 保证 mocksdk 的 ToolDef 与公共 SDK **逐字段对齐**。 +// +// 为什么必须有:mocksdk 是 yaegi 解释执行时的替身 SDK。插件作者用它本地调试, +// 少一个字段就会"写了声明却不报错",直到编译安装后才发现声明没被内核读到。 +// 这种不一致极难察觉,且**不会让任何现有测试失败** —— 所以要显式钉住。 +// +// 这不是理论风险:RecallPolicy、ParallelSafe、Serial 三个字段就是先后漂移的 +// (主 SDK 先加,mocksdk 没跟上)。 +func TestMockSDKToolDefMatchesSDK(t *testing.T) { + mockFields := structFields(t, "plugin.go", "ToolDef") + sdkFields := sdkFieldsOf(t) + + var missing, extra []string + for name := range sdkFields { + if !mockFields[name] { + missing = append(missing, name) + } + } + for name := range mockFields { + if !sdkFields[name] { + extra = append(extra, name) + } + } + if len(missing) > 0 { + t.Errorf("mocksdk.ToolDef 缺少字段 %v —— 插件本地调试时写了声明不报错,装到内核却不生效", missing) + } + if len(extra) > 0 { + t.Errorf("mocksdk.ToolDef 有公共 SDK 没有的字段 %v —— 替身比本体还多,必有一方理解错了", extra) + } +} + +// TestMockSDKToolDefTypesMatch 字段类型也要一致(不只是名字)。 +func TestMockSDKToolDefTypesMatch(t *testing.T) { + rt := reflect.TypeOf(sdk.ToolDef{}) + src, err := os_ReadFile("plugin.go") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "plugin.go", src, 0) + if err != nil { + t.Fatal(err) + } + ast.Inspect(f, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || ts.Name.Name != "ToolDef" { + return true + } + st := ts.Type.(*ast.StructType) + for _, fld := range st.Fields.List { + name := fld.Names[0].Name + sf, exists := rt.FieldByName(name) + if !exists { + continue + } + want := sf.Type.String() + got := exprString(fset, fld.Type) + // 归一化包路径前缀 + got = normalize(got) + if got != normalize(want) { + t.Errorf("mocksdk.ToolDef.%s 类型是 %s,公共 SDK 是 %s", name, got, want) + } + } + return false + }) +} diff --git a/tools/hmapdev/yaegi/mocksdk/plugin.go b/tools/hmapdev/yaegi/mocksdk/plugin.go index cd25006..fb93a0e 100644 --- a/tools/hmapdev/yaegi/mocksdk/plugin.go +++ b/tools/hmapdev/yaegi/mocksdk/plugin.go @@ -90,6 +90,15 @@ type ToolDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json:"context_policy,omitempty"` + RecallPolicy string `json:"recall_policy,omitempty"` + // ParallelSafe / Serial 与公共 SDK 的同名声明项**逐字对齐**。 + // + // ⚠️ 不要只补主 SDK 就以为完事:mocksdk 是 yaegi 解释执行用的**替身**, + // 少一个字段,插件作者在本地调试时"写了声明却不报错",编译安装后才发现 + // 声明没生效 —— 这种不一致极难察觉(黑名单不会自动跟上新执行能力)。 + // TestMockSDKToolDefMatchesSDK 钉住这一致。 + ParallelSafe bool `json:"parallel_safe,omitempty"` + Serial bool `json:"serial,omitempty"` } // 上下文策略取值,与公共 SDK 一致。