mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 17:08:01 +00:00
记忆系统在核心 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 插件把 Triple / Doc 交进来,媒体一律无处安放,且**不报错**。本版补上公开接口 侧缺失的表达能力。 ## 一、类型与接口(全部新增,无签名变更) - `Triple` += `SentenceText`、`MediaDigests` - `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment` - `TextEvent` += `Attachments` - `DocMemoryAPI` += `InsertWithMedia` - `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia` - `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有, 插件只能自己去拿 injector) `MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(内核按字节 去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索 可能命中几十份媒体,把字节全塞回来会撑爆跨进程消息。 媒体注入为什么不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用, 且媒体要等**下一条** tool message 才到模型手上。插件主动发起一轮带媒体的对话、 以及中断注入,需要各自的签名,且媒体在**本轮**就随消息发出。 `Triple.MediaDigests` 非空而 `SentenceText` 为空时,内核会用媒体标记本身充当句子 ——媒体引用挂在句子上,没有句子就无处挂起。插件只需填 digest,标记由内核拼: 要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。 ## 二、修掉两处并发竞态 `sdk/stress_test.go` 的 `-race` 实测报 11 处 DATA RACE,收敛到两个字段: 1. **`PluginSDK` 的 API 字段无锁**。写方是内核(加载/重载插件时依次注入 injector、memory、doc、llm…),读方是插件在 `Start()` 里起的后台 goroutine ——轮询、监听、定时器都要拿 injector 往管道注消息。生产表现是插件重载瞬间 偶发崩溃:读到半个接口值就 nil 解引用。 2. **`autoRestart` 标志无锁**。`SetAutoRestart` 的文档用法本身就是「外部连接建好 后再决定能否自动重启」,而连接建立通常在后台 goroutine;内核 registry 在另一个 goroutine 读 `AutoRestart()` 决定崩溃后重启策略。这对读写天然跨 goroutine。 加 `apiMu sync.RWMutex`。关键约定写进注释:**只在持锁期间取字段值,取完立刻 释放再调用**。持锁调用会把 `InjectInputSync`(阻塞到 agent 回复,可达数分钟) 与 `SetIOInjector` 串到一起,让插件重载卡死。 ## 三、压测(sdk/stress_test.go,13 例) SDK 是被多个 goroutine 同时使用的共享对象,单线程单测全绿不代表并发路径成立。 断言的是不变量而非吞吐: - 媒体注入高并发不丢不串——每次调用带唯一 tag,逐条校验文本与图片 URL 配对。 「不串」是重点:若实现里出现任何共享中间状态(把 blocks 暂存到字段再读出), 高并发下会出现 A 的文本配 B 的图,而两者单独看都「成功」了; - injector 热替换(含替换成 nil,即内核卸载 API 的真实状态); - stop / onRemove handler 恰好一次——契约是「执行后清空,幂等」,执行两次的后果 从重复写文件到 close 已关闭 channel 直接 panic; - `StageContext` 并发读改写无 lost update(媒体链路让 Extra 成为新热点, 而 map 并发写在 Go 里是直接 fatal,recover 接不住); - `OwnTools` scope 不跨插件泄漏; - 媒体类型 JSON 往返字节级一致(9 种长度,含 0/1/2/3 与 base64 分组边界) ——`[]byte` 在 JSON 里是 base64,往返不一致意味着图片静默损坏, 要到 CAS 校验 digest 时才发现,那时已无从追查; - `omitempty` 真的生效(读路径不能出现 `"data"` 键); - nil 依赖全部静默降级不 panic。 ## 四、工具链同步 - `proc_main.go.tmpl`:`procIO` 三个媒体方法、`procDocMemory.InsertWithMedia`。 模板不跟上的后果是**每个外部插件都编不过**(接口未实现),是硬失败; - `proc_runtime_test.go`:方法清单补 `io.injectMedia*` 与 `doc.insertWithMedia`。 漏接线时插件调 `InjectInputMedia` 会静默无效果——模板不发这个 RPC,内核也就 收不到,两边都不报错; - `yaegi/mocksdk`:与公开 SDK 对齐。它此前漂移严重且**没有任何代码对着它编译**, 所以漂移不会被编译器抓到:`Triple` 用的是 `Predicate`,而公开 SDK 一直叫 `Relation` —— 插件在 yaegi 调试期写 `Relation:` 报未知字段,写 `Predicate:` 则 编成 plugin.bin 时报错,两边都不对。 - README 中英双语补媒体接口文档与用法示例。 ## 兼容性 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,不调就不 受影响。17 个 example 插件源码零改动通过类型检查;用 SDK 0.9.2 编的旧 plugin.bin 在新内核上直接建链通过(握手校验的是 ProtocolVersion=1,不是 SDK 版本)。 媒体接口需要核心 1.1.1+(更早的核心没有对应 RPC,调用返回 unknown method)。 `CoreVersion` 保持 1.0.0:它是「SDK 能在其上运行」的下限,媒体是可选能力。
400 lines
14 KiB
Go
400 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"go/parser"
|
||
"go/token"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 子进程运行时模板的静态检查(Part 3)。
|
||
//
|
||
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
|
||
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
|
||
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
|
||
//
|
||
// 模板改为真实 .go 源文件(而非 raw string)的直接收益就是这类检查可行。
|
||
|
||
func loadProcTemplate(t *testing.T) string {
|
||
t.Helper()
|
||
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
|
||
if err != nil {
|
||
t.Fatalf("读取内嵌模板: %v", err)
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
|
||
func stripComments(t *testing.T, src string) string {
|
||
t.Helper()
|
||
fs := token.NewFileSet()
|
||
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
|
||
if err != nil {
|
||
t.Fatalf("解析模板: %v", err)
|
||
}
|
||
out := []byte(src)
|
||
for _, cg := range f.Comments {
|
||
s := fs.Position(cg.Pos()).Offset
|
||
e := fs.Position(cg.End()).Offset
|
||
for i := s; i < e && i < len(out); i++ {
|
||
if out[i] != '\n' {
|
||
out[i] = ' '
|
||
}
|
||
}
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
// 模板必须是合法 Go 源码。
|
||
func TestProcTemplate_ParsesAsGo(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
fs := token.NewFileSet()
|
||
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
|
||
t.Fatalf("模板不是合法 Go 源码: %v", err)
|
||
}
|
||
}
|
||
|
||
// 模板必须提供 main(),且不得含 cgo 痕迹。
|
||
//
|
||
// 零 cgo 是迁移的核心收益之一(§3.7 锁仲裁回内核后整个架构无 cgo);
|
||
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
|
||
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
if !strings.Contains(src, "func main()") {
|
||
t.Error("子进程模板必须有 main() 入口")
|
||
}
|
||
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
|
||
code := stripComments(t, src)
|
||
for _, forbidden := range []string{
|
||
`import "C"`,
|
||
"//export ",
|
||
"C.CString",
|
||
"C.GoString",
|
||
"C.free",
|
||
} {
|
||
if strings.Contains(code, forbidden) {
|
||
t.Errorf("模板不应含 cgo 痕迹 %q(零 cgo 是迁移的核心收益)", forbidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
|
||
//
|
||
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
|
||
// 表现是插件调用返回「未知 method」,测试能提前拦住。
|
||
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
// 51 个 C ABI method id 平移后的名字(§3.2),加 stage 锁仲裁 2 个
|
||
required := []string{
|
||
// 注册面
|
||
"tool.register", "stage.register", "output.register", "api.register", "input.register",
|
||
// IO 注入
|
||
"io.injectText", "io.injectInterrupt", "io.injectTextNoMem", "io.injectInputSync",
|
||
"io.setToolBlocks",
|
||
// 多模态注入(1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
|
||
// 模板不发这个 RPC,内核也就永远收不到,而两边都不报错。
|
||
"io.injectMedia", "io.injectMediaSync", "io.injectInterruptMedia",
|
||
// 生命周期
|
||
"lifecycle.autoRestart",
|
||
// 图记忆
|
||
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
|
||
// 文档记忆
|
||
"doc.query", "doc.insert", "doc.remove", "doc.stats",
|
||
// 文档媒体(1.1.0 新增)
|
||
"doc.insertWithMedia",
|
||
// 知识库
|
||
"knowledge.search", "knowledge.add", "knowledge.list",
|
||
// 文本记忆
|
||
"textmemory.append",
|
||
// 设置
|
||
"settings.get", "settings.set", "settings.registerDef",
|
||
"settings.getCore", "settings.setCore", "settings.listCore",
|
||
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
|
||
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
|
||
"settings.dataDir",
|
||
// LLM
|
||
"llm.listSources", "llm.setSource", "llm.currentSource",
|
||
// 社交图
|
||
"social.getPerson", "social.getNetwork", "social.getTrait",
|
||
"social.getRelations", "social.listPersons",
|
||
// 插件管理
|
||
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
|
||
// 共享段锁仲裁(新增,C ABI 下不存在此概念)
|
||
"stage.lock", "stage.unlock",
|
||
}
|
||
for _, m := range required {
|
||
if !strings.Contains(src, `"`+m+`"`) {
|
||
t.Errorf("模板缺少 core method %q(内核已提供,插件侧未接线)", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 模板必须处理内核发来的全部 7 个调用(原 C ABI 的 7 个 //export)。
|
||
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
for _, m := range []string{
|
||
"handshake",
|
||
"plugin.init", "plugin.start", "plugin.stop",
|
||
"tool.invoke", "stage.invoke", "output.invoke",
|
||
} {
|
||
if !strings.Contains(src, `case "`+m+`"`) {
|
||
t.Errorf("模板未处理内核调用 %q", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
|
||
//
|
||
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
|
||
// 而两边都不报错(同为 []byte)。
|
||
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
|
||
layout := map[string]string{
|
||
"shmOffMagic": "0",
|
||
"shmOffVersion": "4",
|
||
"shmOffArenaBase": "8",
|
||
"shmOffArenaCap": "12",
|
||
"shmOffArenaUsed": "16",
|
||
"shmOffCtxBase": "20",
|
||
"shmOffSeq": "24",
|
||
// 与内核 stageFieldCount / sliceSize 对齐
|
||
"shmStageFieldCount": "18",
|
||
"shmSliceSize": "8",
|
||
"shmVersion": "1",
|
||
}
|
||
constRe := func(name, want string) bool {
|
||
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
|
||
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
|
||
return re.MatchString(src)
|
||
}
|
||
for name, want := range layout {
|
||
if !constRe(name, want) {
|
||
t.Errorf("共享段常量 %s 应为 %s(须与内核 internal/plugin/proc/shm.go 一致)", name, want)
|
||
}
|
||
}
|
||
|
||
// 字段枚举顺序:内核 stageField 的前若干项
|
||
fieldOrder := []string{
|
||
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
|
||
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
|
||
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
|
||
"fTokenUsage", "fErrors",
|
||
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
|
||
}
|
||
idx := -1
|
||
for _, f := range fieldOrder {
|
||
at := strings.Index(src, f)
|
||
if at < 0 {
|
||
t.Fatalf("模板缺少字段常量 %s", f)
|
||
}
|
||
if at <= idx {
|
||
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
|
||
}
|
||
idx = at
|
||
}
|
||
}
|
||
|
||
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
|
||
//
|
||
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
|
||
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
|
||
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
for _, want := range []string{
|
||
"func handleStageInvoke(",
|
||
"stage.lock",
|
||
"readStageContext()",
|
||
"takeStageSnapshot(",
|
||
"writeStageDirty(",
|
||
"stage.unlock",
|
||
} {
|
||
if !strings.Contains(src, want) {
|
||
t.Errorf("stage 处理链路缺少 %q", want)
|
||
}
|
||
}
|
||
|
||
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
|
||
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
|
||
iRead := strings.Index(src, "readStageContext()")
|
||
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
|
||
if iLock < 0 || iRead < 0 || iWrite < 0 {
|
||
t.Fatal("stage 链路关键调用缺失")
|
||
}
|
||
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
|
||
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
|
||
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
|
||
iReadFn := strings.Index(stageFn, "readStageContext()")
|
||
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
|
||
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
|
||
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
|
||
}
|
||
}
|
||
|
||
// 快照必须存序列化字符串而非 Go 值。
|
||
//
|
||
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑:StageContext 的切片字段与读出的值
|
||
// 共享底层内容,handler 原地改元素(sc.ToolResults[0].Result = x)时,
|
||
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
|
||
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
if !strings.Contains(src, "strs map[int]string") ||
|
||
!strings.Contains(src, "jsons map[int]string") {
|
||
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
|
||
}
|
||
if !strings.Contains(src, "json.Marshal(v)") {
|
||
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
|
||
}
|
||
}
|
||
|
||
// arena 用尽必须显式报错,不得静默截断(§4.4 风险登记)。
|
||
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
if !strings.Contains(src, "arena 空间不足") {
|
||
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
|
||
}
|
||
}
|
||
|
||
// 日志必须走 stderr:stdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
|
||
func TestProcTemplate_LogsToStderr(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
|
||
t.Error("日志必须走 stderr,否则会破坏 stdout 的 RPC 帧")
|
||
}
|
||
}
|
||
|
||
// 请求必须在独立 goroutine 里处理。
|
||
//
|
||
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
|
||
// 就没人读应答帧 → 死锁。
|
||
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
if !strings.Contains(src, "go handleKernelRequest(&req)") {
|
||
t.Error("请求须在独立 goroutine 处理(handler 内反向调用内核,同步处理会死锁)")
|
||
}
|
||
}
|
||
|
||
// 协议与共享段版本不匹配必须拒绝,不得半兼容运行。
|
||
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "共享段魔数不匹配"} {
|
||
if !strings.Contains(src, want) {
|
||
t.Errorf("握手应校验并拒绝 %q", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 全平台统一产出 plugin.bin。
|
||
//
|
||
// 这是三套独立 ABI 实现(.so/.dylib/.dll)收敛为单一 RPC 实现的直接后果:
|
||
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
|
||
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
|
||
// Windows 走的是与 Linux 完全相同的 RPC 实现。
|
||
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
|
||
for _, target := range []string{
|
||
"linux/amd64", "linux/arm64",
|
||
"darwin/amd64", "darwin/arm64",
|
||
"windows/amd64",
|
||
"freebsd/amd64",
|
||
} {
|
||
cfg, errMsg := resolveBuild(target)
|
||
if cfg == nil {
|
||
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
|
||
}
|
||
if cfg.entryFile != procEntryFile {
|
||
t.Errorf("%s: 产物应为 %s,实际 %s", target, procEntryFile, cfg.entryFile)
|
||
}
|
||
}
|
||
}
|
||
|
||
// lua 目标仍走解释器路径(entry 字段唯一仍在使用的用途)。
|
||
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
|
||
for _, target := range []string{"lua", ""} {
|
||
cfg, kind := resolveBuild(target)
|
||
if cfg != nil {
|
||
t.Errorf("%q 应返回 nil cfg(Lua 不经 Go 编译)", target)
|
||
}
|
||
if kind != "lua" {
|
||
t.Errorf("%q 应识别为 lua,实际 %q", target, kind)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 不支持的平台明确报错,不静默产出错误产物。
|
||
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
|
||
cfg, errMsg := resolveBuild("plan9/amd64")
|
||
if cfg != nil {
|
||
t.Error("不支持的平台应返回 nil cfg")
|
||
}
|
||
if !strings.Contains(errMsg, "unsupported") {
|
||
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
|
||
}
|
||
}
|
||
|
||
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
|
||
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
|
||
seen := map[string]bool{}
|
||
for _, bt := range allBundleTargets {
|
||
if seen[bt.entry] {
|
||
t.Errorf("zip 条目名重复: %s(会相互覆盖)", bt.entry)
|
||
}
|
||
seen[bt.entry] = true
|
||
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
|
||
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
|
||
}
|
||
}
|
||
if len(allBundleTargets) == 0 {
|
||
t.Error("bundle 目标表不应为空")
|
||
}
|
||
}
|
||
|
||
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
|
||
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
|
||
func TestToolchain_NoCABIResiduals(t *testing.T) {
|
||
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
|
||
data, err := os.ReadFile(f)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", f, err)
|
||
}
|
||
src := stripComments(t, string(data))
|
||
for _, forbidden := range []string{
|
||
"c-shared",
|
||
"CGO_ENABLED=1",
|
||
"detectWindowsCC",
|
||
"generateBridge",
|
||
"tmplLinuxBridge",
|
||
"tmplPluginInitC",
|
||
} {
|
||
if strings.Contains(src, forbidden) {
|
||
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Go 插件的构建不再读 plg.json 的 entry 值。
|
||
//
|
||
// 这是「外部插件零改动」的关键:17 个存量插件的 plg.json 都写着 "plugin.so",
|
||
// 若把 entry 当通道开关,迁移就得改 17 个文件。
|
||
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
|
||
data, err := os.ReadFile("cmd_build.go")
|
||
if err != nil {
|
||
t.Fatalf("读 cmd_build.go: %v", err)
|
||
}
|
||
src := stripComments(t, string(data))
|
||
if strings.Contains(src, "isProcEntry") {
|
||
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin,不看 entry 值")
|
||
}
|
||
// entry 仅剩 Lua 判定这一处用途
|
||
if !strings.Contains(src, "luaEntryFile") {
|
||
t.Error("IsLua 应改用 luaEntryFile 常量")
|
||
}
|
||
}
|