Files
Cyrene_v2/internal/capability/paneldecl/validate.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

253 lines
9.8 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 (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是声明校验的入口与面板/区块层(§5.2.1 B「声明校验」)。字段层在 fieldcheck.go。
// Validate 校验一个插件的完整声明:插件 id 形状 + 逐面板校验 + 插件内 id 唯一性。
//
// 它**只读**:不修补、不改写传入的声明(要存进目录的值由 Register 负责 normalize)。
// 返回 nil 或 *Errors(聚合了**逐条**可分类的违规)。插件 id 非法时仍继续逐面板校验,
// 让调用方一次拿到全部问题 —— 逐轮报错会让插件作者改一轮跑一轮。
func Validate(pluginID string, panels []sdk.Panel) error {
var vs []Violation
vs = append(vs, checkID(pluginID, "pluginID", "插件 id", ErrInvalidPluginID)...)
seen := make(map[string]bool, len(panels))
for i, p := range panels {
path := panelPath(i)
pvs := validatePanelAt(p, path)
if p.ID != "" {
if seen[p.ID] {
pvs = append(pvs, dupViolation(path+".id", "本插件", "面板 id", p.ID))
}
seen[p.ID] = true
}
vs = append(vs, pvs...)
}
return violationsToError(vs)
}
// ValidatePanel 校验单个面板,index 是它在插件声明数组里的下标(只用于错误路径)。
//
// 它存在的理由是 §5.2.1 的拒绝粒度:非法声明「该面板不注册」,所以插件管理器需要**按面板**
// 判定,而不是只能整插件一起拒。
func ValidatePanel(panel sdk.Panel, index int) error {
return violationsToError(validatePanelAt(panel, panelPath(index)))
}
// validatePanelAt 校验一个面板及其全部区块。path 是索引式前缀。
func validatePanelAt(p sdk.Panel, path string) []Violation {
var vs []Violation
vs = append(vs, checkID(p.ID, path+".id", "面板 id", ErrInvalidPanelID)...)
vs = append(vs, checkRequiredText(p.Title, path+".title", titleMaxRunes)...)
vs = append(vs, checkOptionalText(p.Description, path+".description", hintMaxRunes)...)
if g := normalizeGroup(p.Group); !g.Valid() {
vs = append(vs, violation(ErrUnknownGroup, path+".group",
fmt.Sprintf("未知分组 %q(本期分组闭集只有 %q,见 SDK 的 GroupID)", p.Group, sdk.GroupPlugins)))
}
if p.Order < 0 {
vs = append(vs, violation(ErrInvalidOrder, path+".order",
fmt.Sprintf("order 必须 >= 0(小在前),得到 %d", p.Order)))
}
if p.Icon != "" && !validIcon(p.Icon) {
vs = append(vs, violation(ErrInvalidIcon, path+".icon",
fmt.Sprintf("图标名 %q 非法:只允许宿主内置图标名(字母数字与中划线/下划线),不接受 URL 或 SVG 片段", p.Icon)))
}
vs = append(vs, validateSections(p.Sections, path)...)
return vs
}
// checkID 校验一个 id 类取值:空 ⇒ 必填缺失;形状不符 ⇒ 该 id 种类的分类错误。
func checkID(id, path, label string, kind error) []Violation {
if id == "" {
return []Violation{violation(ErrMissingField, path, label+" 必填(空值无法构成路由段/端点段)")}
}
if !validID(id) {
return []Violation{violation(kind, path, fmt.Sprintf(
"%s %q 非法:只允许小写字母数字与中划线分段(如 providers / web-search),长度 ≤ %d",
label, id, idMaxLen))}
}
return nil
}
// validateSections 校验面板的区块列表,并保证区块 id 在面板内唯一。
func validateSections(sections []sdk.Section, panelPrefix string) []Violation {
var vs []Violation
seen := make(map[string]bool, len(sections))
for i, s := range sections {
path := fmt.Sprintf("%s.sections[%d]", panelPrefix, i)
vs = append(vs, validateSectionAt(s, path)...)
if s.ID == "" {
continue
}
if seen[s.ID] {
vs = append(vs, dupViolation(path+".id", "本面板", "区块 id", s.ID))
continue
}
seen[s.ID] = true
}
return vs
}
// validateSectionAt 校验一个区块:类型闭集 → id/标题 → 形状矩阵 → 载荷(字段/列/动作)。
func validateSectionAt(s sdk.Section, path string) []Violation {
if !s.Type.Valid() {
// 类型不在闭集 ⇒ 形状矩阵无从谈起。只报这一条:继续按未知类型推导出的
// 「缺 submit / 缺 columns」全是噪音,会把真正的病因埋掉。
return []Violation{violation(ErrUnknownSectionType, path+".type",
fmt.Sprintf("未知 section 类型 %q(闭集:form|table|status|log)", s.Type))}
}
var vs []Violation
vs = append(vs, checkID(s.ID, path+".id", "区块 id", ErrInvalidSectionID)...)
vs = append(vs, checkRequiredText(s.Title, path+".title", titleMaxRunes)...)
vs = append(vs, checkSectionShape(s, path)...)
vs = append(vs, validateSectionPayload(s, path)...)
return vs
}
// checkSectionShape 按 §5.2.1 的接入点表检查「哪些键必须出现 / 不许出现」。
func checkSectionShape(s sdk.Section, path string) []Violation {
sh := shapeOf(s.Type)
// 六个键 × 四种必要性。写成表是为了让每个键的判定逐行可读、加键时只改一处。
keys := []struct {
key string
req requirement
has bool
}{
{"fields", sh.fields, len(s.Fields) > 0},
{"submit", sh.submit, s.Submit != nil},
{"source", sh.source, s.Source != nil},
{"columns", sh.columns, len(s.Columns) > 0},
{"rowActions", sh.rowActions, len(s.RowActions) > 0},
{"limit", sh.limit, s.Limit != 0},
}
var vs []Violation
for _, k := range keys {
kp := path + "." + k.key
switch k.req {
case required:
if !k.has {
vs = append(vs, violation(ErrMissingField, kp,
fmt.Sprintf("%s 区块必须声明 %s", s.Type, k.key)))
}
case notAllowed:
if k.has {
vs = append(vs, violation(ErrSectionShape, kp,
fmt.Sprintf("%s 区块不消费 %s(形状见 §5.2.1 的接入点表)", s.Type, k.key)))
}
case writeForbidden:
if k.has {
vs = append(vs, violation(ErrReadOnlySection, kp,
fmt.Sprintf("%s 是只读区块,不得声明写面 %s(§5.4.1:写动作一律 owner/admin)", s.Type, k.key)))
}
case optional:
}
}
return vs
}
// validateSectionPayload 校验区块内各元素的载荷(动作名、字段、列、行内动作、limit)。
func validateSectionPayload(s sdk.Section, path string) []Violation {
var vs []Violation
switch s.Type {
case sdk.SectionForm, sdk.SectionStatus:
vs = append(vs, validateFields(s.Fields, path)...)
}
if s.Submit != nil {
vs = append(vs, checkAction(s.Submit.Action, path+".submit.action")...)
}
if s.Source != nil {
vs = append(vs, checkAction(s.Source.Action, path+".source.action")...)
}
if s.Type == sdk.SectionTable {
vs = append(vs, validateColumns(s.Columns, path)...)
vs = append(vs, validateRowActions(s.RowActions, path)...)
}
if s.Type == sdk.SectionLog {
vs = append(vs, checkLimit(s.Limit, path+".limit")...)
}
return vs
}
// checkAction 校验动作名。依据 §5.4.4:插件只给**动作名**、不给 URL,所以这里连
// `:` 与 `/` 都不接受 —— 想塞请求地址的声明在此被拒。空动作名是必填缺失(有 submit /
// source 却没动作,前端点下去什么都不会发生,即半渲染)。
func checkAction(action, path string) []Violation {
if action == "" {
return []Violation{violation(ErrMissingField, path, "动作名必填(插件只给动作名,不给 URL)")}
}
if !validAction(action) {
return []Violation{violation(ErrInvalidAction, path, fmt.Sprintf(
"动作名 %q 非法:只允许点分标识(如 plugin.saveProvider),不得含 `/` 或 `:` 等 URL 成分",
action))}
}
return nil
}
// checkLimit 校验 log 区块的 limit。0 表示未声明(由宿主取默认),非 0 必须在 (0, maxLogLimit]。
func checkLimit(limit int, path string) []Violation {
if limit == 0 {
return nil
}
if limit < 0 || limit > maxLogLimit {
return []Violation{violation(ErrInvalidLimit, path,
fmt.Sprintf("limit 必须在 (0, %d] 内(log 是排障用的只读尾读,超大值会把页面卡死),得到 %d",
maxLogLimit, limit))}
}
return nil
}
// validateColumns 校验表格列:key 形状与唯一性、label 文案。
func validateColumns(cols []sdk.Column, prefix string) []Violation {
var vs []Violation
seen := make(map[string]bool, len(cols))
for i, c := range cols {
path := fmt.Sprintf("%s.columns[%d]", prefix, i)
if c.Key == "" {
vs = append(vs, violation(ErrMissingField, path+".key", "列 key 必填(它是行数据字段名与列的身份)"))
} else if !validFieldKey(c.Key) {
vs = append(vs, violation(ErrInvalidFieldKey, path+".key", fieldKeyDetail(c.Key)))
} else if seen[c.Key] {
vs = append(vs, dupViolation(path+".key", "本表格", "列 key", c.Key))
} else {
seen[c.Key] = true
}
vs = append(vs, checkOptionalText(c.Label, path+".label", titleMaxRunes)...)
}
return vs
}
// validateRowActions 校验行内动作:id 形状与唯一性、label 必填、动作名合法。
func validateRowActions(actions []sdk.RowAction, prefix string) []Violation {
var vs []Violation
seen := make(map[string]bool, len(actions))
for i, a := range actions {
path := fmt.Sprintf("%s.rowActions[%d]", prefix, i)
vs = append(vs, checkID(a.ID, path+".id", "行内动作 id", ErrInvalidRowActionID)...)
// label 必填且**没有可回落的东西**:不像列 label 能回落到 key,没有文字的行内按钮
// 就是半渲染。
vs = append(vs, checkRequiredText(a.Label, path+".label", titleMaxRunes)...)
vs = append(vs, checkAction(a.Action, path+".action")...)
if a.ID == "" {
continue
}
if seen[a.ID] {
vs = append(vs, dupViolation(path+".id", "本表格", "行内动作 id", a.ID))
continue
}
seen[a.ID] = true
}
return vs
}
// dupViolation 生成「同作用域内重复」的违规。三处调用共用同一措辞,避免同一类问题在
// 不同位置报出不同说法。
func dupViolation(path, scope, kind, value string) Violation {
return violation(ErrDuplicateID, path,
fmt.Sprintf("%s %q 在%s内重复(重复会让入口/锚点/提交键无法区分)", kind, value, scope))
}