feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。

- pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形);
  字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错,
  不静默丢)
- internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**,
  不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)·
  when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留
  status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed)
- 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告)

【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿;
我亲手打红变异:把同组同序的回退比较  反成  ⇒ TestCatalog_StableOrder FAIL。
(记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 ——
我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
AskaEth committed 2026-10-06 08:06:20 +08:00
1 parent 6e28b38b08
commit c9144c1096
19 files changed
+3053

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
package paneldecl
import (
"errors"
"fmt"
"strings"
)
// 本文件是校验错误的**可分类**表示。
//
// 为什么不用一句 `invalid`:§5.2.1 要求「非法声明 ⇒ 该面板不注册 + 显式报错」,而错误
// 要同时喂给三种消费者 —— 插件管理器(决定拒哪个面板)、启动期日志(排障)、以及未来
// 的安装响应(把错误码与文案分离,照 Home Assistant 的「错误码与文案分离」,前端可本地化)。
// 所以错误必须是结构化的:哨兵做分类,Path 做定位,Detail 做人读说明。
// 校验失败分类哨兵。调用方用 errors.Is 判类,不靠字符串匹配。
var (
// ErrInvalidPluginID 报告插件 id 不合法(无法作为目录命名空间 / 路由段)。
ErrInvalidPluginID = errors.New("paneldecl: invalid plugin id")
// ErrInvalidPanelID 报告面板 id 不合法。
ErrInvalidPanelID = errors.New("paneldecl: invalid panel id")
// ErrInvalidSectionID 报告区块 id 不合法。
ErrInvalidSectionID = errors.New("paneldecl: invalid section id")
// ErrInvalidRowActionID 报告行内动作 id 不合法。
ErrInvalidRowActionID = errors.New("paneldecl: invalid row action id")
// ErrInvalidFieldKey 报告字段 key 不合法(字段 key 是配置键,字符集比 id 宽,见 shape.go)。
ErrInvalidFieldKey = errors.New("paneldecl: invalid field key")
// ErrMissingField 报告必填项缺失(id / title / section 的 type / field 的 key、type 等)。
ErrMissingField = errors.New("paneldecl: required field missing")
// ErrInvalidText 报告文案非法:空白、含控制字符、或超出长度上限。
ErrInvalidText = errors.New("paneldecl: invalid text")
// ErrUnknownGroup 报告分组不在闭集内(本期只有 plugins)。
ErrUnknownGroup = errors.New("paneldecl: unknown panel group")
// ErrUnknownSectionType 报告 section 类型不在闭集内。
ErrUnknownSectionType = errors.New("paneldecl: unknown section type")
// ErrUnknownFieldType 报告字段类型不在闭集内。
ErrUnknownFieldType = errors.New("paneldecl: unknown field type")
// ErrSectionShape 报告区块形状错误:该类型不该出现的键出现了(如 table 上写 fields)。
ErrSectionShape = errors.New("paneldecl: invalid section shape")
// ErrReadOnlySection 报告只读区块上出现了写面(status/log 上的 submit / rowActions)。
ErrReadOnlySection = errors.New("paneldecl: read-only section declares a write surface")
// ErrInvalidOptionsFrom 报告 optionsFrom 非法:只允许 `plugin:*`,且只允许出现在下拉字段上。
ErrInvalidOptionsFrom = errors.New("paneldecl: invalid optionsFrom")
// ErrInvalidAction 报告动作名非法(允许点分名,禁止 / : 等 URL 成分)。
ErrInvalidAction = errors.New("paneldecl: invalid action name")
// ErrInvalidOrder 报告 order 为负。
ErrInvalidOrder = errors.New("paneldecl: invalid order")
// ErrInvalidBounds 报告 min/max 非法(配在非 number 字段上、NaN、或 min > max)。
ErrInvalidBounds = errors.New("paneldecl: invalid numeric bounds")
// ErrInvalidDefault 报告默认值与字段类型不匹配。
ErrInvalidDefault = errors.New("paneldecl: default value does not match the field type")
// ErrSecretDefault 报告 secret 字段声明了默认值(§5.4.2 读侧无明文)。
ErrSecretDefault = errors.New("paneldecl: secret field must not declare a default")
// ErrInvalidLimit 报告 log 区块的 limit 越界。
ErrInvalidLimit = errors.New("paneldecl: invalid log limit")
// ErrInvalidIcon 报告图标名非法。
ErrInvalidIcon = errors.New("paneldecl: invalid icon name")
// ErrDuplicateID 报告同一作用域内 id / key 重复。
ErrDuplicateID = errors.New("paneldecl: duplicate id")
// ErrUnknownRole 报告角色不在 owner/admin/viewer 之内(查询目录时使用)。
ErrUnknownRole = errors.New("paneldecl: unknown role")
// ErrNilResolver 报告构造目录时没有注入 when 求值上下文来源。
ErrNilResolver = errors.New("paneldecl: nil panel context resolver")
// ErrNilCatalog 报告在 nil *Catalog 上调用了 Register。
ErrNilCatalog = errors.New("paneldecl: nil catalog")
)
// maxReportedViolations 限制聚合错误在 Error() 里铺开的条数:一个坏清单可能有几百条
// 违规,全铺进一行日志会把有用的信息挤掉。结构化的完整列表仍在 Violations 里,不丢。
const maxReportedViolations = 5
// Violation 是一条**可分类**的校验失败。
type Violation struct {
// Kind 是分类哨兵(本文件的 Err*),errors.Is 沿着它判定。
Kind error
// Path 是出问题的位置,索引式 JSON 路径,如
// `adminPanels[0].sections[1].fields[2].type` —— 索引而不是 id,因为出错的可能正是 id。
Path string
// Detail 是人读说明,只讲这一条问题本身,不含他人信息。
Detail string
}
// Error 实现 error。格式固定为 `<分类>: <路径>: <说明>`,便于日志切片与测试断言。
func (v Violation) Error() string {
return fmt.Sprintf("%s: %s: %s", v.Kind, v.Path, v.Detail)
}
// Unwrap 让 errors.Is(err, ErrXxx) 能穿过这条违规。
func (v Violation) Unwrap() error { return v.Kind }
// Errors 是逐条违规的聚合。它的存在是为了「一次把所有问题讲清楚」—— 逐个报错会让插件
// 作者改一轮、跑一轮。
type Errors struct {
// Violations 按发现顺序排列(声明顺序,因此可复现)。
Violations []Violation
}
// Error 实现 error,最多铺开 maxReportedViolations 条,其余只报条数。
func (e *Errors) Error() string {
if e == nil || len(e.Violations) == 0 {
return "paneldecl: no violations"
}
parts := make([]string, 0, maxReportedViolations)
for i, v := range e.Violations {
if i == maxReportedViolations {
parts = append(parts, fmt.Sprintf("...(+%d more)", len(e.Violations)-maxReportedViolations))
break
}
parts = append(parts, v.Error())
}
return fmt.Sprintf("paneldecl: %d violation(s): %s", len(e.Violations), strings.Join(parts, "; "))
}
// Unwrap 返回每一条违规,让 errors.Is / errors.As 能穿透聚合看分类。
func (e *Errors) Unwrap() []error {
if e == nil {
return nil
}
out := make([]error, 0, len(e.Violations))
for _, v := range e.Violations {
out = append(out, v)
}
return out
}
// violation 构造一条违规。
func violation(kind error, path, detail string) Violation {
return Violation{Kind: kind, Path: path, Detail: detail}
}
// violationsToError 把逐条违规收成一个 error:空切片返回 nil(Go 的 nil-error 约定,
// 免得调用方写 `if len(vs) > 0 { return &Errors{vs} }`)。
func violationsToError(vs []Violation) error {
if len(vs) == 0 {
return nil
}
return &Errors{Violations: vs}
}