Why: F07/F08 - registry and catalog accessors shared caller-mutable nested state, and the generation fallback skipped full slot validation, risk metadata and mis-keyed handler slot tables. Evidence: intent package tests pass incl. new isolation/generated cases (verify-u5.json). Impact: no exported signature change; rule and generated paths now share authoritative validation. Refs: feature/agent-stack-audit-remediation-b1 U5
261 lines
7.2 KiB
Go
261 lines
7.2 KiB
Go
package intent
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
// SlotValidator 宿主注册的槽位值校验器。必须纯函数、确定性、无 I/O。
|
||
type SlotValidator func(value string) error
|
||
|
||
var slotValidators sync.Map // name → SlotValidator
|
||
|
||
// RegisterSlotValidator 注册进程级具名校验器;spec 通过 SlotSpec.Validator 引用。
|
||
// 必须在快照构建前注册(构建期会校验名字存在)。
|
||
//
|
||
// Deprecated: 校验器应随 Registry 隔离。请改用 Options.SlotValidators。
|
||
// 全局表只作回落:构建与校验先查作用域表,查不到再查这里。
|
||
func RegisterSlotValidator(name string, fn SlotValidator) {
|
||
slotValidators.Store(name, fn)
|
||
}
|
||
|
||
// LookupSlotValidator 按名查找已注册的全局校验器。
|
||
//
|
||
// Deprecated: 请改用 Options.SlotValidators。本函数只服务全局回落。
|
||
func LookupSlotValidator(name string) (SlotValidator, bool) {
|
||
fn, ok := slotValidators.Load(name)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
v, ok := fn.(SlotValidator)
|
||
return v, ok
|
||
}
|
||
|
||
// resolveSlotValidator 先查作用域表,查不到再回落已弃用的全局表。
|
||
func resolveSlotValidator(scoped map[string]SlotValidator, name string) (SlotValidator, bool) {
|
||
if fn, ok := scoped[name]; ok && fn != nil {
|
||
return fn, true
|
||
}
|
||
return LookupSlotValidator(name)
|
||
}
|
||
|
||
// 内置归一化步骤:trim / lower / upper。
|
||
var builtinNormalizers = map[string]func(string) string{
|
||
"trim": strings.TrimSpace,
|
||
"lower": strings.ToLower,
|
||
"upper": strings.ToUpper,
|
||
}
|
||
|
||
func normalizeSlotValue(v string, ops []string) string {
|
||
for _, op := range ops {
|
||
if fn, ok := builtinNormalizers[op]; ok {
|
||
v = fn(v)
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
// compiledSlot 编译后的槽位抽取与校验产物。
|
||
type compiledSlot struct {
|
||
key string
|
||
required bool
|
||
label string
|
||
question string
|
||
re *regexp.Regexp
|
||
group int
|
||
enumSet map[string]bool
|
||
enum []string
|
||
formatRe *regexp.Regexp
|
||
normalize []string
|
||
validator SlotValidator
|
||
}
|
||
|
||
// validate 对归一化后的值做确定性校验;返回(原因, 是否合法)。
|
||
func (s *compiledSlot) validate(v string) (string, bool) {
|
||
if s.formatRe != nil && !s.formatRe.MatchString(v) {
|
||
return "format mismatch: " + s.formatRe.String(), false
|
||
}
|
||
if s.enumSet != nil && !s.enumSet[v] {
|
||
return "value not in enum", false
|
||
}
|
||
if s.validator != nil {
|
||
if err := s.validator(v); err != nil {
|
||
return "validator: " + err.Error(), false
|
||
}
|
||
}
|
||
return "", true
|
||
}
|
||
|
||
// slotCapture 一次槽位抽取的捕获区间。多槽位各自第一次匹配;区间重叠时
|
||
// 两边都标 span overlap,不写入 slots。库内不做重叠消解。
|
||
type slotCapture struct {
|
||
key string
|
||
start int
|
||
end int
|
||
value string
|
||
ok bool
|
||
reason string
|
||
required bool
|
||
matched bool
|
||
}
|
||
|
||
// extractSlots 抽取并归一化槽位。
|
||
// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法,或与另一槽位区间重叠(key→原因)。
|
||
// 必填且非法只进 invalid(宿主可区分"没给"与"给错了"来决定澄清话术)。
|
||
func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, missing []string, invalid map[string]string) {
|
||
if len(cs.slots) == 0 {
|
||
return nil, nil, nil
|
||
}
|
||
caps := make([]slotCapture, 0, len(cs.slots))
|
||
for _, s := range cs.slots {
|
||
loc := s.re.FindStringSubmatchIndex(input)
|
||
g := s.group
|
||
if loc == nil || 2*g+1 >= len(loc) || loc[2*g] < 0 || loc[2*g] == loc[2*g+1] {
|
||
caps = append(caps, slotCapture{key: s.key, required: s.required})
|
||
continue
|
||
}
|
||
raw := input[loc[2*g]:loc[2*g+1]]
|
||
v := normalizeSlotValue(raw, s.normalize)
|
||
reason, ok := s.validate(v)
|
||
caps = append(caps, slotCapture{
|
||
key: s.key, start: loc[2*g], end: loc[2*g+1], value: v,
|
||
ok: ok, reason: reason, required: s.required, matched: true,
|
||
})
|
||
}
|
||
overlap := map[int]bool{}
|
||
for i := range caps {
|
||
if !caps[i].matched {
|
||
continue
|
||
}
|
||
for j := i + 1; j < len(caps); j++ {
|
||
if !caps[j].matched {
|
||
continue
|
||
}
|
||
if caps[i].start < caps[j].end && caps[j].start < caps[i].end {
|
||
overlap[i] = true
|
||
overlap[j] = true
|
||
}
|
||
}
|
||
}
|
||
for i, c := range caps {
|
||
if !c.matched {
|
||
if c.required {
|
||
missing = append(missing, c.key)
|
||
}
|
||
continue
|
||
}
|
||
if overlap[i] {
|
||
if invalid == nil {
|
||
invalid = map[string]string{}
|
||
}
|
||
invalid[c.key] = "span overlap"
|
||
continue
|
||
}
|
||
if !c.ok {
|
||
if invalid == nil {
|
||
invalid = map[string]string{}
|
||
}
|
||
invalid[c.key] = c.reason
|
||
continue
|
||
}
|
||
if slots == nil {
|
||
slots = map[string]string{}
|
||
}
|
||
slots[c.key] = c.value
|
||
}
|
||
return slots, missing, invalid
|
||
}
|
||
|
||
// validateProvidedSlots 校验调用方直接给出的槽位表(生成器路径)。
|
||
// 与 extractSlots 同款结果形态:归一化 → format/enum/validator;必填缺失进 missing;
|
||
// 捕获到但不合法进 invalid;未知键进 invalid(reason=unknown slot key)。
|
||
// 非法或未知键的值不写入 slots。
|
||
func (cs *compiledSpec) validateProvidedSlots(provided map[string]string) (slots map[string]string, missing []string, invalid map[string]string) {
|
||
byKey := make(map[string]*compiledSlot, len(cs.slots))
|
||
for i := range cs.slots {
|
||
byKey[cs.slots[i].key] = &cs.slots[i]
|
||
}
|
||
for k, raw := range provided {
|
||
s, ok := byKey[k]
|
||
if !ok {
|
||
if invalid == nil {
|
||
invalid = map[string]string{}
|
||
}
|
||
invalid[k] = "unknown slot key"
|
||
continue
|
||
}
|
||
v := normalizeSlotValue(raw, s.normalize)
|
||
reason, valid := s.validate(v)
|
||
if !valid {
|
||
if invalid == nil {
|
||
invalid = map[string]string{}
|
||
}
|
||
invalid[k] = reason
|
||
continue
|
||
}
|
||
if slots == nil {
|
||
slots = map[string]string{}
|
||
}
|
||
slots[k] = v
|
||
}
|
||
for _, s := range cs.slots {
|
||
if !s.required {
|
||
continue
|
||
}
|
||
if _, ok := slots[s.key]; ok {
|
||
continue
|
||
}
|
||
// 必填且已标 invalid 的不重复进 missing(与 extractSlots 同款)。
|
||
if invalid != nil {
|
||
if _, bad := invalid[s.key]; bad {
|
||
continue
|
||
}
|
||
}
|
||
missing = append(missing, s.key)
|
||
}
|
||
return slots, missing, invalid
|
||
}
|
||
|
||
func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot, error) {
|
||
re, err := regexp.Compile(spec.Pattern)
|
||
if err != nil {
|
||
return compiledSlot{}, newError(InvalidSpec, "slot pattern 编译失败",
|
||
"field", "slots", "key", spec.Key, "error", err.Error())
|
||
}
|
||
cs := compiledSlot{
|
||
key: spec.Key,
|
||
required: spec.Required,
|
||
label: spec.Label,
|
||
question: spec.Question,
|
||
re: re,
|
||
group: effectiveGroup(spec.Group),
|
||
normalize: append([]string(nil), spec.Normalize...),
|
||
}
|
||
if len(spec.Enum) > 0 {
|
||
cs.enum = append([]string(nil), spec.Enum...)
|
||
cs.enumSet = make(map[string]bool, len(spec.Enum))
|
||
for _, e := range spec.Enum {
|
||
cs.enumSet[e] = true
|
||
}
|
||
}
|
||
if spec.Format != "" {
|
||
fre, err := regexp.Compile(spec.Format)
|
||
if err != nil {
|
||
return compiledSlot{}, newError(InvalidSpec, "slot format 编译失败",
|
||
"field", "slots", "key", spec.Key, "format", spec.Format, "error", err.Error())
|
||
}
|
||
cs.formatRe = fre
|
||
}
|
||
if spec.Validator != "" {
|
||
fn, ok := resolveSlotValidator(scoped, spec.Validator)
|
||
if !ok {
|
||
return compiledSlot{}, newError(InvalidSpec, "slot validator 未注册",
|
||
"field", "slots", "key", spec.Key, "validator", spec.Validator,
|
||
"hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator")
|
||
}
|
||
cs.validator = fn
|
||
}
|
||
return cs, nil
|
||
}
|