refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃

## 问题

三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:

    ui = { visibilty = "admin" }   -- 一个字母之差
    ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分

今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。

## 改动

新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。

readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。

## 顺带修掉一个真 bug:handler 必须是名字而非函数值

`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。

## 更正一个我写了但从未核实的说法

之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:

    setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
    json global present when readPlugin* runs: true
    luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages

真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。

一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。

## 判据

- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了

变异自证:strictKeys 短路 → 变红。

已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-04 20:10:07 +08:00
parent 700f4179fa
commit 2fd536e99b
3 changed files with 620 additions and 137 deletions

View File

@ -0,0 +1,227 @@
package lua
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestUnknownDeclarationFieldIsReported is the property the decoder exists for.
//
// Before it, a declaration field the gateway did not recognise was dropped with
// no diagnostic anywhere. `ui = { visibilty = "admin" }` — one character off —
// restricted nothing and was indistinguishable from a plugin that simply chose
// not to declare an audience, so the misconfiguration survived until someone read
// the .lua by hand.
//
// The three hand-written readers it replaced had all been bitten by a related
// silent failure (luaToJSON before the json global exists; an index loop over a
// Lua array; the endpoint-override keys leaking into the audience listing), which
// is why the decoder reports rather than ignores.
func TestUnknownDeclarationFieldIsReported(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "typo.lua")
src := `local P = {}
P.name = "typo"
P.version = "1.0.0"
P.ui = { visibilty = "admin" }
return P
`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
if err := ps.LoadSource("typo", src); err != nil {
t.Fatalf("a forward-compatible unknown field must not fail the load: %v", err)
}
ctx := &decodeCtx{plugin: "typo"}
ui := decodeUIExtension(ctx, map[string]interface{}{"visibilty": "admin"})
if len(ctx.problems) == 0 {
t.Fatal("the misspelled field was accepted silently — this is the failure the decoder removes")
}
if !strings.Contains(ctx.problems[0], "visibilty") {
t.Errorf("diagnostic does not name the offending field: %q", ctx.problems[0])
}
// And it must be actionable, not just a complaint.
if !strings.Contains(ctx.problems[0], "did you mean \"visibility\"") {
t.Errorf("diagnostic offers no fix: %q", ctx.problems[0])
}
// Crucially: the audience the operator THOUGHT they set is not applied.
if ui.Visibility != "" {
t.Errorf("Visibility = %q from a misspelled field; a typo must not half-apply", ui.Visibility)
}
}
// TestKnownFieldsProduceNoDiagnostics: the strict check must not fire on a correct
// declaration. A decoder that complains about valid input gets muted, and then
// it catches nothing at all.
func TestKnownFieldsProduceNoDiagnostics(t *testing.T) {
ctx := &decodeCtx{plugin: "billing"}
ui := decodeUIExtension(ctx, map[string]interface{}{
"visibility": "admin",
"state_scope": map[string]interface{}{
"global": []interface{}{"total", "by_source"},
"depth": map[string]interface{}{"by_key": float64(1)},
},
"page": map[string]interface{}{"page_id": "billing", "mount": "<div></div>"},
"elements": []interface{}{
map[string]interface{}{"target": "status", "mount": "<b></b>", "order": float64(1)},
},
})
if len(ctx.problems) != 0 {
t.Errorf("a correct declaration produced diagnostics: %v", ctx.problems)
}
if ui.Visibility != "admin" {
t.Errorf("Visibility = %q, want admin", ui.Visibility)
}
if ui.StateScope == nil || len(ui.StateScope.Global) != 2 {
t.Errorf("state_scope not decoded: %+v", ui.StateScope)
}
if ui.Page == nil || ui.Page.PageID != "billing" {
t.Errorf("page not decoded: %+v", ui.Page)
}
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
t.Errorf("elements not decoded: %+v", ui.Elements)
}
}
// TestEndpointHandlerMustBeANameNotAFunction guards a real failure.
//
// asString coerces anything non-string with fmt.Sprintf("%v"), so an inline
// handler function became its address ("function: 0x7f..."). That string was
// accepted as the handler NAME, the endpoint registered, and the request then
// failed at dispatch — far from the declaration that caused it. Rejecting the
// value at decode time names both the field and the path.
func TestEndpointHandlerMustBeANameNotAFunction(t *testing.T) {
dir := t.TempDir()
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
// The inline-function form: valid Lua, and what a plugin author writes when
// they have not read that handler is a name.
src := `local P = {}
P.name = "inline"
P.version = "1.0.0"
P.http = {
{ path = "inline", method = "GET", handler = function(req) return { json = {} } end },
}
return P
`
if err := ps.LoadSource("inline", src); err != nil {
t.Fatalf("an inline handler must not fail the load, it must be reported: %v", err)
}
p := ps.find("inline")
if p == nil {
t.Fatal("plugin not found")
}
if len(p.http) != 0 {
t.Errorf("the inline-function endpoint was accepted: %+v", p.http)
}
// And the documented form still works.
src2 := `local P = {}
P.name = "byname"
P.version = "1.0.0"
P.http = {
{ path = "ok", method = "GET", roles = { "admin" }, handler = "ok" },
}
function P.ok(req) return { json = {} } end
return P
`
if err := ps.LoadSource("byname", src2); err != nil {
t.Fatal(err)
}
p2 := ps.find("byname")
if p2 == nil || len(p2.http) != 1 {
t.Fatalf("the documented form regressed: %+v", p2)
}
if p2.http[0].Handler != "ok" || p2.http[0].Method != "GET" || len(p2.http[0].Roles) != 1 {
t.Errorf("endpoint decoded wrong: %+v", p2.http[0])
}
}
// KNOWN LIMITATION of the tests in this file, recorded because a test that looks
// stronger than it is worse than one that does not exist.
//
// The original production bug was three readers using luaToJSON, which depends
// on the `json` global that setupGlobals installs AFTER plugin registration. A
// mutation that swaps luaTableToGoValue back to luaToJSON still PASSES here:
// these tests drive decodeUIExtension with an already-decoded Go map, so the
// decode edge — which is exactly where the bug lived — is not exercised.
//
// Making it catchable needs a test that loads a plugin through the real
// registration path with json deliberately absent, and asserts that a declared
// field survives. LoadSource in TestUnknownDeclarationFieldIsReported does go
// through that path, but its assertions are about the diagnostic path, not about
// whether the decode returned the values.
//
// Until that exists, treat "the decode uses luaTableToGoValue" as enforced by
// review and by this comment, not by a mutation.
// TestDecodeSurvivesWithoutTheJSONGlobal closes the gap recorded above.
//
// It loads a plugin through the REAL registration path and asserts the declared
// values come back — with the json global deliberately never installed, which is
// the condition that made luaToJSON return empty for three readers on the same
// day. Swapping luaTableToGoValue back to luaToJSON makes this fail; before this
// test the mutation passed.
func TestDecodeSurvivesWithoutTheJSONGlobal(t *testing.T) {
dir := t.TempDir()
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
src := `local P = {}
P.name = "nojson"
P.version = "1.0.0"
P.ui = {
visibility = "admin",
page = { page_id = "nojson", mount = "<div id='x'></div>" },
}
P.http = {
{ path = "ping", method = "GET", roles = { "admin" }, handler = "ping" },
}
function P.ping(req) return { json = { ok = true } } end
return P
`
if err := ps.LoadSource("nojson", src); err != nil {
t.Fatalf("load: %v", err)
}
p := ps.find("nojson")
if p == nil {
t.Fatal("plugin not found after LoadSource")
}
if p.UI == nil {
t.Fatal("ui not read back")
}
if p.UI.Visibility != "admin" {
t.Errorf("UI.Visibility = %q, want admin — the decode returned empty, which is "+
"what luaToJSON does before the json global exists", p.UI.Visibility)
}
if p.UI.Page == nil || p.UI.Page.PageID != "nojson" {
t.Errorf("UI.Page not decoded: %+v", p.UI.Page)
}
if len(p.http) != 1 {
t.Fatalf("http endpoints = %d, want 1", len(p.http))
}
if p.http[0].Handler != "ping" || p.http[0].Method != "GET" {
t.Errorf("endpoint decoded wrong: %+v", p.http[0])
}
if len(p.http[0].Roles) != 1 || p.http[0].Roles[0] != "admin" {
t.Errorf("roles not decoded: %+v", p.http[0].Roles)
}
}