纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
139 lines
6.7 KiB
Go
139 lines
6.7 KiB
Go
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}
|
||
}
|