From 9206353858724ee3a29b5a9856f2aa2a5f4bea6a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 15:51:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(hmapdev):=20=E9=A1=B9=E7=9B=AE=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=20SDK=20=E7=89=88=E6=9C=AC=EF=BC=8C=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=93=BE=E6=8D=AE=E6=AD=A4=E8=87=AA=E5=8A=A8=E9=80=89=EF=BC=88?= =?UTF-8?q?plg.json=20=E7=9A=84=20sdk=20=E5=AD=97=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前项目里没有任何「我要哪版 SDK」的声明:go.mod 的 require 是个 Go 模块版本, 而工具链实际用的是存储里的 current——谁改过 current 就拿谁的版本编,出错时 表现为莫名其妙的编译错误(本轮就踩过:存储里只有陈旧的 v0.8.0,模板项目 首次构建报 undefined: sdk.InjectOptions)。 - `plg.json` 新增 `sdk` 字段:本插件针对的 SDK 版本。`hmapdev init` 生成时写入 **完整版本号**(如 "1.2.0")。 - `hmapdev build` 按声明的版本在本地 SDK 存储里定位:命中则用它并把 go.mod 的 require/replace 同步到该版本;未命中则报**可执行**的错误(列出已装版本 + `hmapdev sdk install vX.Y.Z`),绝不静默退化成 current。 - **区间写法("1.2")被拒绝**并说明规矩:SDK 版本跟随内核中版本、patch 位恒为 .0, 一条内核线只有一个 SDK 版本(写区间会让人误以为同一条线里还能挑不同 SDK)。 - 产物 `plugin.json` 记录实际选中的版本(`sdk`),便于追溯「这个 .hmap 是哪版编的」。 - 显式 `--sdk-path` / `plg.json sdk_path` 优先(本机改 SDK 联调的路径),此路径下也尽力记录版本。 - 存量项目(plg.json 无 sdk 字段)行为不变,向后兼容。 顺带修一处自相矛盾:解析出 1.2.0 之外的版本时,原先只改 replace 而 require 保持旧版本, 一旦有人删掉 replace 就会静默用回旧 SDK 编译(`go list -m` 报的也是假版本)。 验证:单测 10 例(精确命中/带 v 前缀目录/区间写法被拒并说明规矩/未命中给可执行命令/ 空存储给安装指引/非法值拒绝/杂项目录不干扰)+ 反向验证(把版本比较退化成字典序, 「1.2.x 取最新」用例立刻变红,证明判据能发现缺陷)。 E2E:init → plg.json `"sdk": "1.2.0"`;build → 精确解析、go.mod require/replace 一致、 产物 plugin.json 记录 sdk;声明不存在的版本 → 可执行报错;无 sdk 字段 → 照旧构建。 --- tools/hmapdev/cmd_build.go | 43 +++++++++- tools/hmapdev/cmd_init.go | 15 ++++ tools/hmapdev/sdk_resolve.go | 135 ++++++++++++++++++++++++++++++ tools/hmapdev/sdk_resolve_test.go | 103 +++++++++++++++++++++++ tools/hmapdev/templates.go | 1 + 5 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 tools/hmapdev/sdk_resolve.go create mode 100644 tools/hmapdev/sdk_resolve_test.go diff --git a/tools/hmapdev/cmd_build.go b/tools/hmapdev/cmd_build.go index 52554dd..2eef47e 100644 --- a/tools/hmapdev/cmd_build.go +++ b/tools/hmapdev/cmd_build.go @@ -79,6 +79,22 @@ func cmdBuild(args []string) { return } + // 项目可在 plg.json 里声明 sdk(中版本或完整版本,如 "1.2" / "1.2.1"); + // 显式 --sdk-path / plg.json 的 sdk_path 优先 —— 那是直指源码目录, + // 常用于本机改 SDK 的联调场景。 + if sdkPath == "" && strings.TrimSpace(plg.SDK) != "" { + dir, ver, err := ResolveSDKForProject(plg.SDK) + if err != nil { + fmt.Printf("error: %v\n", err) + os.Exit(1) + } + sdkPath, plg.ResolvedSDK = dir, ver + fmt.Printf("[hmapdev] SDK %s(项目声明 sdk=%s)\n", ver, plg.SDK) + } else if sdkPath != "" && plg.ResolvedSDK == "" { + // 走的是显式路径:尽力记录它是哪版(读不到就不记,不因此失败) + plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath)) + } + // Ensure go.mod exists with correct SDK path sdkModule := ensureGoMod(plg, sdkPath) @@ -269,6 +285,10 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) { if len(plg.Tags) > 0 { m["tags"] = plg.Tags } + // 记录「用哪版 SDK 编的」:插件产物与内核协议绑定,出问题时这是第一个要看的字段。 + if plg.ResolvedSDK != "" { + m["sdk"] = plg.ResolvedSDK + } data, _ := json.MarshalIndent(m, "", " ") os.WriteFile("plugin.json", data, 0644) } @@ -370,7 +390,28 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) string { } keep = append(keep, line) } - if alreadyExists { + // 同步 require 版本:replace 指向 1.2.1 而 require 还写 1.2.0 是自相矛盾的 + // —— 有人删掉 replace 就会静默退回旧版本去编(`go list -m` 报的也是假版本)。 + // 以本次真正选中的版本为准改写 require 行。 + requireChanged := false + if v := normalizeSDKVersion(plg.ResolvedSDK); v != "" { + want := "require " + sdkModule + " v" + v + for i, line := range keep { + t := strings.TrimSpace(line) + if !strings.HasPrefix(t, "require ") { + continue + } + parts := strings.Fields(t) + if len(parts) >= 3 && parts[1] == sdkModule { + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + if t != want { + keep[i] = indent + want + requireChanged = true + } + } + } + } + if alreadyExists && !requireChanged { return sdkModule } keep = append(keep, replaceLine, "") diff --git a/tools/hmapdev/cmd_init.go b/tools/hmapdev/cmd_init.go index 4aa4b23..e617542 100644 --- a/tools/hmapdev/cmd_init.go +++ b/tools/hmapdev/cmd_init.go @@ -34,6 +34,18 @@ type PlgConfig struct { GoVersion string `json:"go_version,omitempty"` Replaces map[string]string `json:"replaces,omitempty"` SourceDirs []string `json:"source_dirs,omitempty"` + + // SDK 声明本插件针对的 SDK **接口版本**(中版本或完整版本,如 "1.2" / "1.2.1")。 + // + // 为何需要:工具链存储里可能装有多个 SDK 版本,而插件产物与内核是协议绑定的—— + // 不给声明就只能猜(旧行为是直接用 current:谁改过 current 就拿谁的版本编, + // 出错时表现为莫名其妙的编译错误)。写中版本表示「只要 1.2 这条接口线, + // 补丁由工具链挑最新」(patch 只含工具链/打包修复,接口不变,见 README 版本语义)。 + SDK string `json:"sdk,omitempty"` + + // ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填), + // 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。 + ResolvedSDK string `json:"-"` } // TargetList parses the Targets string into a slice. @@ -159,6 +171,9 @@ func cmdInit(args []string) { data.SDKModule = sdkMod data.SDKVersion = "v" + sdkVer data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/") + // 声明**完整版本号**:SDK 版本跟随内核中版本、patch 位恒为 .0, + // 一条内核线只对应一个 SDK 版本(build 时按此解析,见 ResolveSDKForProject)。 + data.Plg.SDK = normalizeSDKVersion(sdkVer) } if err := os.MkdirAll(dir, 0755); err != nil { diff --git a/tools/hmapdev/sdk_resolve.go b/tools/hmapdev/sdk_resolve.go new file mode 100644 index 0000000..3554549 --- /dev/null +++ b/tools/hmapdev/sdk_resolve.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// sdkVersionEntry 是本地 SDK 存储里的一个版本。 +// +// Dir 是存储目录名(历史上有 "v0.8.0" 与 "1.2.0" 两种写法都出现过,所以 +// 目录名与规范化版本号要分开存),Version 是去掉 v 前缀的 x.y.z。 +type sdkVersionEntry struct { + Dir string + Version string +} + +// normalizeSDKVersion 去掉常见的前缀写法,得到 x.y.z。 +func normalizeSDKVersion(v string) string { + return strings.TrimPrefix(strings.TrimSpace(v), "v") +} + +// parseSDKVersion 解析 x.y.z / x.y(后者补 0)。 +func parseSDKVersion(v string) (maj, min, patch int, ok bool) { + v = normalizeSDKVersion(v) + parts := strings.Split(v, ".") + if len(parts) < 2 || len(parts) > 3 { + return 0, 0, 0, false + } + nums := make([]int, 0, 3) + for _, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return 0, 0, 0, false + } + nums = append(nums, n) + } + for len(nums) < 3 { + nums = append(nums, 0) + } + return nums[0], nums[1], nums[2], true +} + +// compareSDKVersion 比较两个 x.y.z(ab 返回 1)。 +func compareSDKVersion(a, b string) int { + amaj, amin, apat, aok := parseSDKVersion(a) + bmaj, bmin, bpat, bok := parseSDKVersion(b) + if !aok || !bok { + return strings.Compare(normalizeSDKVersion(a), normalizeSDKVersion(b)) + } + for _, d := range [][2]int{{amaj, bmaj}, {amin, bmin}, {apat, bpat}} { + switch { + case d[0] < d[1]: + return -1 + case d[0] > d[1]: + return 1 + } + } + return 0 +} + +// listInstalledSDKs 列出存储里已安装的 SDK,按版本升序。 +func listInstalledSDKs() []sdkVersionEntry { + store := sdkStore() + entries, err := os.ReadDir(store) + if err != nil { + return nil + } + var out []sdkVersionEntry + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if name == "current" || strings.HasPrefix(name, ".") { + continue + } + v := normalizeSDKVersion(name) + if _, _, _, ok := parseSDKVersion(v); !ok { + continue // 非版本目录(用户放别的东西进去时不误判) + } + out = append(out, sdkVersionEntry{Dir: name, Version: v}) + } + sort.Slice(out, func(i, j int) bool { return compareSDKVersion(out[i].Version, out[j].Version) < 0 }) + return out +} + +// ResolveSDKForProject 按项目声明的 SDK 版本(plg.json 的 "sdk" 字段)在本地存储里定位 SDK。 +// +// 声明必须是**完整版本号**(x.y.z,如 "1.2.0"):SDK 的版本纪律是「跟随内核中版本, +// patch 位恒为 .0」(内核的 patch 不碰公开接口 → SDK 不跟版),所以一条内核线 +// 只对应一个 SDK 版本号,写 "1.2" 这种区间写法既不必要、又容易让人以为 +// 「同一条线里还能挑不同 SDK」。工具链直接拒它,顺便把这条规矩说清楚。 +// +// 找不到时必须报**可执行**的错误:列出已装版本 + 可直接粘贴的安装命令 —— +// 只说 "not found" 会让人以为是工具链坏了。 +func ResolveSDKForProject(declared string) (dir, version string, err error) { + declared = normalizeSDKVersion(declared) + if strings.Count(declared, ".") != 2 { + return "", "", fmt.Errorf( + "plg.json 的 sdk 字段 %q 必须是完整版本号(如 \"1.2.0\")——\n"+ + " SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本", declared) + } + maj, min, pat, ok := parseSDKVersion(declared) + if !ok { + return "", "", fmt.Errorf("plg.json 的 sdk 字段 %q 不是合法版本号(写法:\"1.2.0\")", declared) + } + + installed := listInstalledSDKs() + for i := range installed { + e := installed[i] + emaj, emin, epat, _ := parseSDKVersion(e.Version) + if emaj == maj && emin == min && epat == pat { + return filepath.Join(sdkStore(), e.Dir), e.Version, nil + } + } + + // 未命中:给出可执行的下一步 + var have []string + for _, e := range installed { + have = append(have, e.Version) + } + avail := "(存储里还没有任何 SDK)" + if len(have) > 0 { + avail = "已安装:" + strings.Join(have, ", ") + } + return "", "", fmt.Errorf( + "项目声明 sdk=%s,但本地 SDK 存储里没有这个版本;%s\n"+ + " 安装:hmapdev sdk install v%s\n"+ + " 查看:hmapdev sdk list", + declared, avail, declared) +} diff --git a/tools/hmapdev/sdk_resolve_test.go b/tools/hmapdev/sdk_resolve_test.go new file mode 100644 index 0000000..7b55c27 --- /dev/null +++ b/tools/hmapdev/sdk_resolve_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// withSDKStore 把 SDK 存储指到临时目录(sdkStore 读 HOME),并造出给定版本目录。 +func withSDKStore(t *testing.T, versions ...string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + store := filepath.Join(home, ".homeagent", sdkDirName) + if err := os.MkdirAll(store, 0755); err != nil { + t.Fatal(err) + } + for _, v := range versions { + if err := os.MkdirAll(filepath.Join(store, v), 0755); err != nil { + t.Fatal(err) + } + } +} + +// 项目声明中版本 → 挑该接口线里最新的补丁;声明完整版本 → 精确命中。 +// +// 为什么允许中版本是关键判据:patch 位只含工具链/打包修复(接口不变), +// 让项目声明 "1.2" 而不是死钉 "1.2.0",才能既跟得上工具链修复又不跨接口线。 +func TestResolveSDKForProject(t *testing.T) { + t.Run("区间写法(1.2)被拒并说明版本纪律", func(t *testing.T) { + // 判据:SDK 的 patch 位恒为 .0 → 一条内核线只有一个 SDK 版本, + // 区间写法会让人误以为「同一条线里还能挑版本」,所以直接拒。 + withSDKStore(t, "1.2.0") + _, _, err := ResolveSDKForProject("1.2") + if err == nil { + t.Fatal("1.2 这种区间写法应被拒绝") + } + for _, want := range []string{"完整版本号", "patch 位恒为 .0"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("拒绝理由里应说清规矩(缺少 %q): %v", want, err) + } + } + }) + + t.Run("完整版本精确命中", func(t *testing.T) { + withSDKStore(t, "1.2.0", "1.3.0") + _, ver, err := ResolveSDKForProject("1.2.0") + if err != nil || ver != "1.2.0" { + t.Fatalf("精确命中失败: ver=%s err=%v", ver, err) + } + }) + + t.Run("存储目录带 v 前缀也能命中", func(t *testing.T) { + withSDKStore(t, "v1.2.0") + dir, ver, err := ResolveSDKForProject("1.2.0") + if err != nil { + t.Fatal(err) + } + if ver != "1.2.0" || !strings.HasSuffix(dir, "v1.2.0") { + t.Fatalf("带 v 前缀的目录名未被识别: dir=%s ver=%s", dir, ver) + } + }) + + t.Run("未命中要给出可执行命令与已装清单", func(t *testing.T) { + withSDKStore(t, "1.2.0") + _, _, err := ResolveSDKForProject("2.0.0") + if err == nil { + t.Fatal("应报错") + } + msg := err.Error() + for _, want := range []string{"sdk=2.0.0", "hmapdev sdk install", "hmapdev sdk list", "1.2.0"} { + if !strings.Contains(msg, want) { + t.Fatalf("错误信息缺少 %q(要可执行,不能只说 not found):\n%s", want, msg) + } + } + }) + + t.Run("空存储也能给出安装指引", func(t *testing.T) { + withSDKStore(t) + _, _, err := ResolveSDKForProject("1.2.0") + if err == nil || !strings.Contains(err.Error(), "hmapdev sdk install v1.2.0") { + t.Fatalf("空存储时应提示装哪个版本: %v", err) + } + }) + + t.Run("非法声明直接拒绝", func(t *testing.T) { + withSDKStore(t, "1.2.0") + for _, bad := range []string{"abc", "1", "1.2", "1.2.3.4", "-1.2.0"} { + if _, _, err := ResolveSDKForProject(bad); err == nil { + t.Fatalf("非法版本 %q 应被拒绝(宁可报错也不许当通配符)", bad) + } + } + }) + + t.Run("非版本目录不参与匹配", func(t *testing.T) { + withSDKStore(t, "1.2.0", "backup-old", ".hidden", "current") + _, ver, err := ResolveSDKForProject("1.2.0") + if err != nil || ver != "1.2.0" { + t.Fatalf("杂项目录不应干扰: ver=%s err=%v", ver, err) + } + }) +} diff --git a/tools/hmapdev/templates.go b/tools/hmapdev/templates.go index b691f77..7fbbd47 100644 --- a/tools/hmapdev/templates.go +++ b/tools/hmapdev/templates.go @@ -9,6 +9,7 @@ const tmplPlgJSON = `{ "description": "{{.Plg.Description}}", "author": "{{.Plg.Author}}", "entry": "{{.Plg.Entry}}", + "sdk": "{{.Plg.SDK}}", "tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}], "targets": "{{.Plg.Targets}}" }