| 1 | package extension |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | ) |
| 7 | |
| 8 | // ContributionKind names one category of runtime capability. The wire names |
| 9 | // are stable for the same reason as Scope's. |
| 10 | type ContributionKind string |
| 11 | |
| 12 | const ( |
| 13 | KindTool ContributionKind = "tool" |
| 14 | KindSkill ContributionKind = "skill" |
| 15 | KindCommand ContributionKind = "command" |
| 16 | KindPrompt ContributionKind = "prompt" |
| 17 | KindHook ContributionKind = "hook" |
| 18 | KindMCPServer ContributionKind = "mcp_server" |
| 19 | KindProvider ContributionKind = "provider" |
| 20 | KindTheme ContributionKind = "theme" |
| 21 | KindUIAction ContributionKind = "ui_action" |
| 22 | KindInterceptor ContributionKind = "interceptor" |
| 23 | KindStrategy ContributionKind = "strategy" |
| 24 | ) |
| 25 | |
| 26 | // knownKind reports whether k is a declared kind; the builder rejects unknown |
| 27 | // kinds so a misspelled manifest cannot silently drop a capability. |
| 28 | func knownKind(k ContributionKind) bool { |
| 29 | switch k { |
| 30 | case KindTool, KindSkill, KindCommand, KindPrompt, KindHook, KindMCPServer, |
| 31 | KindProvider, KindTheme, KindUIAction, KindInterceptor, KindStrategy: |
| 32 | return true |
| 33 | default: |
| 34 | return false |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // additiveKind reports whether every contribution of this kind survives |
| 39 | // resolution. Hooks and interceptors accumulate across sources by design — |
| 40 | // shadowing them would let one package silently disable another package's |
| 41 | // safety hook, which is the opposite of what hooks are for. |
| 42 | func additiveKind(k ContributionKind) bool { |
| 43 | return k == KindHook || k == KindInterceptor |
| 44 | } |
| 45 | |
| 46 | // Contribution is one capability offered to the kernel. |
| 47 | type Contribution struct { |
| 48 | // Kind is the capability category. |
| 49 | Kind ContributionKind |
| 50 | // ID is the canonical identifier within the kind: the tool name, the |
| 51 | // command's full (package-qualified) name, the skill's slash-or-bare name, |
| 52 | // a hook's "event#n" sequence, the MCP server name, the provider ref |
| 53 | // "provider/model", "plugin:<plugin>:<theme>" for themes, and so on. |
| 54 | // Winner rules key on (Kind, ID). |
| 55 | ID string |
| 56 | // Source is the provenance; its Scope drives shadowing. |
| 57 | Source ContributionSource |
| 58 | // Priority orders interceptors (see SortInterceptors) and breaks catalog |
| 59 | // ties within one tier for other kinds. Interceptor priorities must pass |
| 60 | // ValidatePriority. |
| 61 | Priority int |
| 62 | // Payload is the existing concrete value — skill.Skill, command.Command, |
| 63 | // plugin.Spec, provider.Descriptor, tool.Tool, ... — deliberately not |
| 64 | // re-wrapped so downstream consumers keep working with the types they |
| 65 | // already know. |
| 66 | Payload any |
| 67 | // Order is the per-contributor registration sequence: the index of this |
| 68 | // contribution inside its contributor's returned slice, stamped by the |
| 69 | // Builder. It makes otherwise-identical contributions from one source |
| 70 | // deterministic (first registration wins) without depending on map |
| 71 | // iteration or discovery order. |
| 72 | Order int |
| 73 | } |
| 74 | |
| 75 | // Catalog is the collected set of contributions. It is appended to during |
| 76 | // discovery and frozen into the snapshot at Freeze; adding to a frozen |
| 77 | // catalog panics, because mutating a published snapshot's contents would |
| 78 | // invalidate its CacheHash and every consumer's assumption of stability. |
| 79 | type Catalog struct { |
| 80 | contribs []Contribution |
| 81 | frozen bool |
| 82 | } |
| 83 | |
| 84 | // NewCatalog returns an empty, mutable catalog. |
| 85 | func NewCatalog() *Catalog { return &Catalog{} } |
| 86 | |
| 87 | // Add appends contributions. It panics on a frozen catalog: freeze marks the |
| 88 | // point where the effective set became observable, and growth past that point |
| 89 | // is a wiring bug, not data. |
| 90 | func (c *Catalog) Add(contribs ...Contribution) { |
| 91 | if c.frozen { |
| 92 | panic("extension: Add on frozen catalog") |
| 93 | } |
| 94 | c.contribs = append(c.contribs, contribs...) |
| 95 | } |
| 96 | |
| 97 | // Frozen reports whether the catalog has been sealed into a snapshot. |
| 98 | func (c *Catalog) Frozen() bool { return c.frozen } |
| 99 | |
| 100 | // freeze seals the catalog. Only the Builder calls it. |
| 101 | func (c *Catalog) freeze() { c.frozen = true } |
| 102 | |
| 103 | // Len returns the number of contributions. |
| 104 | func (c *Catalog) Len() int { return len(c.contribs) } |
| 105 | |
| 106 | // All returns every contribution in deterministic order. The returned slice |
| 107 | // is a copy; mutating it cannot affect the catalog. |
| 108 | func (c *Catalog) All() []Contribution { |
| 109 | out := make([]Contribution, len(c.contribs)) |
| 110 | copy(out, c.contribs) |
| 111 | SortContributions(out) |
| 112 | return out |
| 113 | } |
| 114 | |
| 115 | // ByKind returns the contributions of one kind in deterministic order (a |
| 116 | // copy, like All). |
| 117 | func (c *Catalog) ByKind(kind ContributionKind) []Contribution { |
| 118 | out := make([]Contribution, 0, len(c.contribs)) |
| 119 | for _, ct := range c.contribs { |
| 120 | if ct.Kind == kind { |
| 121 | out = append(out, ct) |
| 122 | } |
| 123 | } |
| 124 | SortContributions(out) |
| 125 | return out |
| 126 | } |
| 127 | |
| 128 | // Get returns the contributions matching (kind, id) in deterministic order. |
| 129 | // Additive kinds can legitimately return several entries. |
| 130 | func (c *Catalog) Get(kind ContributionKind, id string) []Contribution { |
| 131 | out := make([]Contribution, 0, 1) |
| 132 | for _, ct := range c.contribs { |
| 133 | if ct.Kind == kind && ct.ID == id { |
| 134 | out = append(out, ct) |
| 135 | } |
| 136 | } |
| 137 | SortContributions(out) |
| 138 | return out |
| 139 | } |
| 140 | |
| 141 | // SortContributions orders contributions deterministically for display and |
| 142 | // assembly: kind first so categories stay together, then shadowing tier |
| 143 | // (highest first, so the effective winner reads first), priority (higher |
| 144 | // first), then the identity fields. The final Source tiebreaks make the order |
| 145 | // total — without them, identical (Kind, ID) pairs from one contributor would |
| 146 | // inherit input order, which depends on contributor registration order. |
| 147 | func SortContributions(cs []Contribution) { |
| 148 | sort.SliceStable(cs, func(i, j int) bool { |
| 149 | a, b := cs[i], cs[j] |
| 150 | if a.Kind != b.Kind { |
| 151 | return a.Kind < b.Kind |
| 152 | } |
| 153 | if ra, rb := tierRank(a.Source.Scope), tierRank(b.Source.Scope); ra != rb { |
| 154 | return ra > rb |
| 155 | } |
| 156 | if a.Priority != b.Priority { |
| 157 | return a.Priority > b.Priority |
| 158 | } |
| 159 | if a.Source.PluginID != b.Source.PluginID { |
| 160 | return a.Source.PluginID < b.Source.PluginID |
| 161 | } |
| 162 | if a.ID != b.ID { |
| 163 | return a.ID < b.ID |
| 164 | } |
| 165 | if a.Order != b.Order { |
| 166 | return a.Order < b.Order |
| 167 | } |
| 168 | if a.Source.Origin != b.Source.Origin { |
| 169 | return a.Source.Origin < b.Source.Origin |
| 170 | } |
| 171 | return a.Source.Path < b.Source.Path |
| 172 | }) |
| 173 | } |
| 174 | |
| 175 | // ConflictError reports same-tier duplicates of one canonical ID from |
| 176 | // different sources for a shadowed kind. The kernel makes these hard failures |
| 177 | // because today's alternative — a silent last-writer-wins — lets an installed |
| 178 | // package override another package's capability with no trace. |
| 179 | type ConflictError struct { |
| 180 | Kind ContributionKind |
| 181 | ID string |
| 182 | Sources []ContributionSource |
| 183 | } |
| 184 | |
| 185 | func (e *ConflictError) Error() string { |
| 186 | labels := make([]string, 0, len(e.Sources)) |
| 187 | for _, s := range e.Sources { |
| 188 | labels = append(labels, s.label()) |
| 189 | } |
| 190 | return fmt.Sprintf("extension: conflicting %s %q claimed by %v", e.Kind, e.ID, labels) |
| 191 | } |
| 192 | |
| 193 | // Conflicts reports every same-tier multi-source duplicate of a canonical ID |
| 194 | // among shadowed (non-additive) kinds. Hooks and interceptors are additive |
| 195 | // and never appear here. The result is deterministically ordered. |
| 196 | func (c *Catalog) Conflicts() []ConflictError { |
| 197 | groups := map[ContributionKind]map[string][]Contribution{} |
| 198 | for _, ct := range c.contribs { |
| 199 | if additiveKind(ct.Kind) { |
| 200 | continue |
| 201 | } |
| 202 | byID := groups[ct.Kind] |
| 203 | if byID == nil { |
| 204 | byID = map[string][]Contribution{} |
| 205 | groups[ct.Kind] = byID |
| 206 | } |
| 207 | byID[ct.ID] = append(byID[ct.ID], ct) |
| 208 | } |
| 209 | var out []ConflictError |
| 210 | for kind, byID := range groups { |
| 211 | for id, cs := range byID { |
| 212 | sources, ok := conflictingSources(cs) |
| 213 | if !ok { |
| 214 | continue |
| 215 | } |
| 216 | out = append(out, ConflictError{Kind: kind, ID: id, Sources: sources}) |
| 217 | } |
| 218 | } |
| 219 | sort.Slice(out, func(i, j int) bool { |
| 220 | if out[i].Kind != out[j].Kind { |
| 221 | return out[i].Kind < out[j].Kind |
| 222 | } |
| 223 | return out[i].ID < out[j].ID |
| 224 | }) |
| 225 | return out |
| 226 | } |
| 227 | |
| 228 | // conflictingSources returns the distinct sources tied at the highest tier |
| 229 | // among cs, when more than one source sits there. Sources are deduped by |
| 230 | // their identity key and returned in deterministic order. |
| 231 | func conflictingSources(cs []Contribution) ([]ContributionSource, bool) { |
| 232 | best := -1 |
| 233 | for _, ct := range cs { |
| 234 | if r := tierRank(ct.Source.Scope); r > best { |
| 235 | best = r |
| 236 | } |
| 237 | } |
| 238 | seen := map[string]bool{} |
| 239 | var sources []ContributionSource |
| 240 | for _, ct := range cs { |
| 241 | if tierRank(ct.Source.Scope) != best { |
| 242 | continue |
| 243 | } |
| 244 | key := ct.Source.key() |
| 245 | if seen[key] { |
| 246 | continue |
| 247 | } |
| 248 | seen[key] = true |
| 249 | sources = append(sources, ct.Source) |
| 250 | } |
| 251 | sort.Slice(sources, func(i, j int) bool { return sources[i].key() < sources[j].key() }) |
| 252 | return sources, len(sources) > 1 |
| 253 | } |
| 254 |