fix(hmapdev): mocksdk 补齐 ToolDef 声明项,并加字段/类型一致性判据

## 问题

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/
This commit is contained in:
JianFeeeee
2026-09-27 15:01:29 +08:00
parent bd73a9b241
commit 1d7c330cfc
2 changed files with 159 additions and 0 deletions

View File

@ -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
})
}

View File

@ -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 一致。