Files
Cyrene_v2/internal/capability/paneldecl/errors.go
T
AskaEth c9144c1096
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
2026-10-06 08:06:20 +08:00

139 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
}