返回 DeepSeek-Reasonix
capability.go
根目录 / internal / extensioncontract / capability.go
1 // Package extensioncontract holds the leaf capability identity types shared by
2 // the plugin manifest parser and the extension kernel. It must stay free of
3 // business imports so both layers can depend on it without cycles.
4 package extensioncontract
5
6 import (
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "fmt"
11 "strings"
12
13 "golang.org/x/mod/semver"
14 )
15
16 // CapabilityKey is a namespaced capability identity. Namespace + Kind + ID
17 // form the stable address used by dependency resolution and conflict reports.
18 type CapabilityKey struct {
19 Namespace string `json:"namespace"`
20 Kind string `json:"kind"`
21 ID string `json:"id"`
22 }
23
24 // String returns the canonical wire form namespace/kind/id.
25 func (k CapabilityKey) String() string {
26 return k.Namespace + "/" + k.Kind + "/" + k.ID
27 }
28
29 // Validate rejects empty or whitespace-bearing key parts.
30 func (k CapabilityKey) Validate() error {
31 if strings.TrimSpace(k.Namespace) == "" {
32 return fmt.Errorf("capability namespace is required")
33 }
34 if strings.TrimSpace(k.Kind) == "" {
35 return fmt.Errorf("capability kind is required")
36 }
37 if strings.TrimSpace(k.ID) == "" {
38 return fmt.Errorf("capability id is required")
39 }
40 if strings.ContainsAny(k.Namespace, " \t\n") || strings.ContainsAny(k.Kind, " \t\n") || strings.ContainsAny(k.ID, " \t\n") {
41 return fmt.Errorf("capability key parts must not contain whitespace")
42 }
43 return nil
44 }
45
46 // Capability is a concrete provided capability with a version and optional
47 // schema hash. Provider, tool, and UI capabilities require a stable schema hash.
48 type Capability struct {
49 Key CapabilityKey `json:"key"`
50 Version string `json:"version"`
51 SchemaHash string `json:"schemaHash,omitempty"`
52 }
53
54 // Validate checks key shape and version/schema rules for the capability kind.
55 func (c Capability) Validate() error {
56 if err := c.Key.Validate(); err != nil {
57 return err
58 }
59 if !semver.IsValid(normalizeVersion(c.Version)) {
60 return fmt.Errorf("capability %s: invalid version %q", c.Key, c.Version)
61 }
62 if requiresSchemaHash(c.Key.Kind) && strings.TrimSpace(c.SchemaHash) == "" {
63 return fmt.Errorf("capability %s: schemaHash is required for kind %q", c.Key, c.Key.Kind)
64 }
65 return nil
66 }
67
68 // CanonicalHash fingerprints the capability identity used by epochs and
69 // snapshot cache metadata.
70 func (c Capability) CanonicalHash() string {
71 type wire struct {
72 Namespace string `json:"namespace"`
73 Kind string `json:"kind"`
74 ID string `json:"id"`
75 Version string `json:"version"`
76 SchemaHash string `json:"schemaHash"`
77 }
78 raw, err := json.Marshal(wire{
79 Namespace: c.Key.Namespace,
80 Kind: c.Key.Kind,
81 ID: c.Key.ID,
82 Version: normalizeVersion(c.Version),
83 SchemaHash: strings.TrimSpace(c.SchemaHash),
84 })
85 if err != nil {
86 // json.Marshal only fails on unsupported types; wire is plain strings.
87 return ""
88 }
89 sum := sha256.Sum256(raw)
90 return "sha256:" + hex.EncodeToString(sum[:])
91 }
92
93 // Requirement is a dependency on a capability, with an optional version range.
94 type Requirement struct {
95 Capability
96 VersionRange string `json:"versionRange,omitempty"`
97 Optional bool `json:"optional,omitempty"`
98 }
99
100 // Validate checks the requirement identity and, when set, the version range.
101 func (r Requirement) Validate() error {
102 if err := r.Key.Validate(); err != nil {
103 return err
104 }
105 if v := strings.TrimSpace(r.Version); v != "" && !semver.IsValid(normalizeVersion(v)) {
106 return fmt.Errorf("requirement %s: invalid version %q", r.Key, r.Version)
107 }
108 if rangeExpr := strings.TrimSpace(r.VersionRange); rangeExpr != "" {
109 if err := validateVersionRange(rangeExpr); err != nil {
110 return fmt.Errorf("requirement %s: %w", r.Key, err)
111 }
112 }
113 return nil
114 }
115
116 // SatisfiedBy reports whether provided meets this requirement (key match,
117 // optional schema hash pin, and semver range / exact version).
118 func (r Requirement) SatisfiedBy(provided Capability) bool {
119 if r.Key != provided.Key {
120 return false
121 }
122 if pin := strings.TrimSpace(r.SchemaHash); pin != "" && pin != strings.TrimSpace(provided.SchemaHash) {
123 return false
124 }
125 pv := normalizeVersion(provided.Version)
126 if !semver.IsValid(pv) {
127 return false
128 }
129 if exact := strings.TrimSpace(r.Version); exact != "" {
130 return semver.Compare(pv, normalizeVersion(exact)) == 0
131 }
132 rangeExpr := strings.TrimSpace(r.VersionRange)
133 if rangeExpr == "" {
134 return true
135 }
136 return matchVersionRange(rangeExpr, pv)
137 }
138
139 func requiresSchemaHash(kind string) bool {
140 switch strings.ToLower(strings.TrimSpace(kind)) {
141 case "provider", "tool", "ui", "uiaction":
142 return true
143 default:
144 return false
145 }
146 }
147
148 // normalizeVersion ensures a leading "v" for golang.org/x/mod/semver.
149 func normalizeVersion(v string) string {
150 v = strings.TrimSpace(v)
151 if v == "" {
152 return ""
153 }
154 if strings.HasPrefix(v, "v") || strings.HasPrefix(v, "V") {
155 return "v" + strings.TrimPrefix(strings.TrimPrefix(v, "v"), "V")
156 }
157 return "v" + v
158 }
159
160 // validateVersionRange accepts a simple comma-separated set of comparisons
161 // such as ">=1.0.0", ">=1.0.0,<2.0.0".
162 func validateVersionRange(expr string) error {
163 for part := range strings.SplitSeq(expr, ",") {
164 part = strings.TrimSpace(part)
165 if part == "" {
166 return fmt.Errorf("empty version range clause")
167 }
168 op, ver, ok := splitRangeClause(part)
169 if !ok {
170 return fmt.Errorf("invalid version range clause %q", part)
171 }
172 if op == "" || !semver.IsValid(normalizeVersion(ver)) {
173 return fmt.Errorf("invalid version range clause %q", part)
174 }
175 }
176 return nil
177 }
178
179 func matchVersionRange(expr, version string) bool {
180 for part := range strings.SplitSeq(expr, ",") {
181 part = strings.TrimSpace(part)
182 op, ver, ok := splitRangeClause(part)
183 if !ok {
184 return false
185 }
186 target := normalizeVersion(ver)
187 cmp := semver.Compare(version, target)
188 switch op {
189 case ">=", "":
190 if cmp < 0 {
191 return false
192 }
193 case ">":
194 if cmp <= 0 {
195 return false
196 }
197 case "<=":
198 if cmp > 0 {
199 return false
200 }
201 case "<":
202 if cmp >= 0 {
203 return false
204 }
205 case "=", "==":
206 if cmp != 0 {
207 return false
208 }
209 default:
210 return false
211 }
212 }
213 return true
214 }
215
216 func splitRangeClause(part string) (op, ver string, ok bool) {
217 for _, candidate := range []string{">=", "<=", "==", ">", "<", "="} {
218 if strings.HasPrefix(part, candidate) {
219 return candidate, strings.TrimSpace(part[len(candidate):]), true
220 }
221 }
222 // Bare version means exact match.
223 if semver.IsValid(normalizeVersion(part)) {
224 return "=", part, true
225 }
226 return "", "", false
227 }
228
228 lines GO