a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
574 lines
19 KiB
Go
574 lines
19 KiB
Go
package contracts
|
|
|
|
import "strings"
|
|
|
|
// CrossLink represents a matched provider-consumer pair, possibly across repos.
|
|
type CrossLink struct {
|
|
ContractID string `json:"contract_id"`
|
|
Provider Contract `json:"provider"`
|
|
Consumer Contract `json:"consumer"`
|
|
CrossRepo bool `json:"cross_repo"`
|
|
}
|
|
|
|
// MatchResult holds the output of a matching pass.
|
|
type MatchResult struct {
|
|
Matched []CrossLink `json:"matched"`
|
|
OrphanProviders []Contract `json:"orphan_providers"`
|
|
OrphanConsumers []Contract `json:"orphan_consumers"`
|
|
}
|
|
|
|
// Match analyses a registry and pairs providers with consumers by
|
|
// contract ID, bounded by the (workspace, project) boundary:
|
|
//
|
|
// - Providers and consumers in different effective workspaces never
|
|
// pair. Each workspace is matched independently — the across-
|
|
// workspace contracts become orphans on their own side.
|
|
// - Providers and consumers in the same workspace but different
|
|
// projects do not pair either: a project owns its own surface and
|
|
// a sibling project's consumer is treated as an orphan that needs
|
|
// an explicit inter-project import to wire up. Iteration 1 keeps
|
|
// it simple: orphan rather than pair.
|
|
//
|
|
// "Effective" means: WorkspaceID / ProjectID if set, else RepoPrefix —
|
|
// the "missing → repo-name" default. So the previous behaviour (one
|
|
// repo = one workspace = one project) still drops out for callers
|
|
// that haven't started populating the slugs yet.
|
|
//
|
|
// The CrossRepo flag stays on a CrossLink whose provider and consumer
|
|
// have different RepoPrefixes (legitimately so — two repos belonging
|
|
// to one workspace, e.g. `tuck-api` provider matched with `tuck-app`
|
|
// consumer when both declare WorkspaceID = "tuck").
|
|
//
|
|
// After the exact-ID pairing, a second pass joins the RPC family
|
|
// (gRPC + Thrift, plus tRPC in its own cohort) by canonical
|
|
// service/method names — see joinRPCCanonical. IDL definitions and
|
|
// generated-stub call sites frequently disagree on the literal
|
|
// contract ID (package-qualified vs bare service names, camelCase vs
|
|
// PascalCase method casing, service-level registrations vs
|
|
// method-level calls); for tRPC the router-namespace spelling differs
|
|
// between the server's router variable and the client's proxy chain
|
|
// while the procedure name stays stable. The canonical join recovers
|
|
// those pairs so cross-service traversal doesn't stop at a spelling
|
|
// difference.
|
|
func Match(reg *Registry) MatchResult {
|
|
var result MatchResult
|
|
|
|
// Collect every contract once (the byID lists already cover all
|
|
// contracts) and bucket them by (effectiveWorkspace,
|
|
// effectiveProject, ID, role). We can't just iterate AllIDs and
|
|
// then split by workspace/project because two providers for the
|
|
// same ID in different projects must be reported as separate
|
|
// orphan groups, not lumped together.
|
|
type bucketKey struct {
|
|
workspace string
|
|
project string
|
|
id string
|
|
}
|
|
providers := make(map[bucketKey][]Contract)
|
|
consumers := make(map[bucketKey][]Contract)
|
|
|
|
for _, id := range reg.AllIDs() {
|
|
for _, c := range reg.ByID(id) {
|
|
key := bucketKey{
|
|
workspace: c.EffectiveWorkspace(),
|
|
project: c.EffectiveProject(),
|
|
id: id,
|
|
}
|
|
switch c.Role {
|
|
case RoleProvider:
|
|
providers[key] = append(providers[key], c)
|
|
case RoleConsumer:
|
|
consumers[key] = append(consumers[key], c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pair within each bucket; emit matched links plus orphans.
|
|
seen := make(map[bucketKey]struct{})
|
|
for key, provs := range providers {
|
|
seen[key] = struct{}{}
|
|
cons := consumers[key]
|
|
if len(cons) == 0 {
|
|
result.OrphanProviders = append(result.OrphanProviders, provs...)
|
|
continue
|
|
}
|
|
for _, consumer := range cons {
|
|
for _, provider := range provs {
|
|
result.Matched = append(result.Matched, CrossLink{
|
|
ContractID: key.id,
|
|
Provider: provider,
|
|
Consumer: consumer,
|
|
CrossRepo: provider.RepoPrefix != consumer.RepoPrefix,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
for key, cons := range consumers {
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
// No provider in this bucket — every consumer is orphaned.
|
|
// Orphan, never pair across the boundary even when an
|
|
// ID-equivalent exists in a sibling workspace.
|
|
result.OrphanConsumers = append(result.OrphanConsumers, cons...)
|
|
}
|
|
|
|
joinRPCCanonical(&result)
|
|
joinTRPCCanonical(&result)
|
|
|
|
return result
|
|
}
|
|
|
|
// isRPCFamily reports whether a contract belongs to the RPC IDL
|
|
// family the canonical-name join pairs across. gRPC and Thrift share
|
|
// the same generated-stub surface (`New<Service>Client(...)`), so a
|
|
// code-side consumer detected as grpc legitimately pairs with a
|
|
// thrift IDL definition of the same service.
|
|
func isRPCFamily(c Contract) bool {
|
|
return c.Type == ContractGRPC || c.Type == ContractThrift
|
|
}
|
|
|
|
// rpcServiceMethod extracts the canonical (service, method) join key
|
|
// from an RPC-family contract, both lowercased. The service name is
|
|
// stripped of any namespace/package qualifier (`billing.v1.Users` →
|
|
// `users`) and methods compare case-insensitively because generated
|
|
// stubs re-case them per language convention (Go GetUser vs TS
|
|
// getUser). Falls back to parsing the contract ID's
|
|
// `<type>::<Service>[::<Method>]` segments when Meta is missing. An
|
|
// empty method means the contract is service-level (a client
|
|
// construction or a server registration without method granularity).
|
|
func rpcServiceMethod(c Contract) (service, method string) {
|
|
if c.Meta != nil {
|
|
service, _ = c.Meta["service"].(string)
|
|
method, _ = c.Meta["method"].(string)
|
|
}
|
|
if service == "" || method == "" {
|
|
parts := strings.Split(c.ID, "::")
|
|
if service == "" && len(parts) >= 2 {
|
|
service = parts[1]
|
|
}
|
|
if method == "" && len(parts) >= 3 {
|
|
method = parts[2]
|
|
}
|
|
}
|
|
if dot := strings.LastIndex(service, "."); dot >= 0 {
|
|
service = service[dot+1:]
|
|
}
|
|
return strings.ToLower(service), strings.ToLower(method)
|
|
}
|
|
|
|
// rpcGroupID picks the contract ID that names the joined group: the
|
|
// method-level side wins (it is strictly more specific), provider
|
|
// first so two method-level sides group under the provider's ID — the
|
|
// ID every exact-matched link for the same RPC already uses.
|
|
func rpcGroupID(provider, consumer Contract) string {
|
|
if _, pm := rpcServiceMethod(provider); pm != "" {
|
|
return provider.ID
|
|
}
|
|
if _, cm := rpcServiceMethod(consumer); cm != "" {
|
|
return consumer.ID
|
|
}
|
|
return provider.ID
|
|
}
|
|
|
|
// matcherIdentity is the per-record identity key the orphan-removal
|
|
// bookkeeping uses. Mirrors removeContract's field set so two registry
|
|
// entries that the Registry treats as distinct stay distinct here.
|
|
func matcherIdentity(c Contract) string {
|
|
return c.ID + "|" + c.FilePath + "|" + c.SymbolID + "|" + string(c.Role) + "|" + c.RepoPrefix
|
|
}
|
|
|
|
// joinRPCCanonical pairs the RPC-family orphans left over from exact-
|
|
// ID matching by canonical service/method names, within the same
|
|
// (workspace, project) boundary the exact pass uses. Three shapes are
|
|
// recovered:
|
|
//
|
|
// - method-level consumer ↔ method-level provider whose IDs differ
|
|
// only in service qualification or method casing (TS camelCase
|
|
// stubs vs proto PascalCase RPCs);
|
|
// - service-level consumer (bare client construction) ↔ every
|
|
// provider of that service;
|
|
// - service-level provider (Go `Register<Service>Server` site) ↔
|
|
// every consumer of that service.
|
|
//
|
|
// Joined contracts are removed from the orphan lists; the emitted
|
|
// CrossLinks group under the method-level side's contract ID (see
|
|
// rpcGroupID) so bridge materialisation keeps per-RPC granularity.
|
|
func joinRPCCanonical(result *MatchResult) {
|
|
type svcKey struct{ ws, proj, svc string }
|
|
type methodKey struct {
|
|
ws, proj, svc, method string
|
|
}
|
|
|
|
// Index every RPC-family contract on BOTH sides of the existing
|
|
// result — matched and orphaned. A service-level orphan must be
|
|
// able to join contracts that already exact-matched (e.g. a TS
|
|
// client construction joining a proto RPC that a Go consumer
|
|
// already paired with).
|
|
var allProviders, allConsumers []Contract
|
|
for _, m := range result.Matched {
|
|
allProviders = append(allProviders, m.Provider)
|
|
allConsumers = append(allConsumers, m.Consumer)
|
|
}
|
|
allProviders = append(allProviders, result.OrphanProviders...)
|
|
allConsumers = append(allConsumers, result.OrphanConsumers...)
|
|
|
|
provByMethod := make(map[methodKey][]Contract)
|
|
provBySvc := make(map[svcKey][]Contract)
|
|
provSeen := make(map[string]struct{})
|
|
for _, p := range allProviders {
|
|
if !isRPCFamily(p) {
|
|
continue
|
|
}
|
|
// The matched list repeats a provider once per consumer it
|
|
// paired with; index each record once.
|
|
idKey := matcherIdentity(p)
|
|
if _, dup := provSeen[idKey]; dup {
|
|
continue
|
|
}
|
|
provSeen[idKey] = struct{}{}
|
|
svc, method := rpcServiceMethod(p)
|
|
if svc == "" {
|
|
continue
|
|
}
|
|
sk := svcKey{p.EffectiveWorkspace(), p.EffectiveProject(), svc}
|
|
provBySvc[sk] = append(provBySvc[sk], p)
|
|
if method != "" {
|
|
provByMethod[methodKey{sk.ws, sk.proj, svc, method}] = append(
|
|
provByMethod[methodKey{sk.ws, sk.proj, svc, method}], p)
|
|
}
|
|
}
|
|
|
|
consByMethod := make(map[methodKey][]Contract)
|
|
consBySvc := make(map[svcKey][]Contract)
|
|
consSeen := make(map[string]struct{})
|
|
for _, c := range allConsumers {
|
|
if !isRPCFamily(c) {
|
|
continue
|
|
}
|
|
idKey := matcherIdentity(c)
|
|
if _, dup := consSeen[idKey]; dup {
|
|
continue
|
|
}
|
|
consSeen[idKey] = struct{}{}
|
|
svc, method := rpcServiceMethod(c)
|
|
if svc == "" {
|
|
continue
|
|
}
|
|
sk := svcKey{c.EffectiveWorkspace(), c.EffectiveProject(), svc}
|
|
consBySvc[sk] = append(consBySvc[sk], c)
|
|
if method != "" {
|
|
consByMethod[methodKey{sk.ws, sk.proj, svc, method}] = append(
|
|
consByMethod[methodKey{sk.ws, sk.proj, svc, method}], c)
|
|
}
|
|
}
|
|
|
|
joinedProv := make(map[string]struct{})
|
|
joinedCons := make(map[string]struct{})
|
|
linked := make(map[string]struct{})
|
|
emit := func(p, c Contract) {
|
|
lk := matcherIdentity(p) + "->" + matcherIdentity(c)
|
|
if _, dup := linked[lk]; dup {
|
|
return
|
|
}
|
|
linked[lk] = struct{}{}
|
|
result.Matched = append(result.Matched, CrossLink{
|
|
ContractID: rpcGroupID(p, c),
|
|
Provider: p,
|
|
Consumer: c,
|
|
CrossRepo: p.RepoPrefix != c.RepoPrefix,
|
|
})
|
|
joinedProv[matcherIdentity(p)] = struct{}{}
|
|
joinedCons[matcherIdentity(c)] = struct{}{}
|
|
}
|
|
|
|
// Orphan consumers seek providers.
|
|
for _, c := range result.OrphanConsumers {
|
|
if !isRPCFamily(c) {
|
|
continue
|
|
}
|
|
svc, method := rpcServiceMethod(c)
|
|
if svc == "" {
|
|
continue
|
|
}
|
|
sk := svcKey{c.EffectiveWorkspace(), c.EffectiveProject(), svc}
|
|
if method != "" {
|
|
provs := provByMethod[methodKey{sk.ws, sk.proj, svc, method}]
|
|
if len(provs) == 0 {
|
|
// No method-level provider — fall back to service-
|
|
// level providers only. Joining a different method's
|
|
// provider would be wrong.
|
|
for _, p := range provBySvc[sk] {
|
|
if _, pm := rpcServiceMethod(p); pm == "" {
|
|
provs = append(provs, p)
|
|
}
|
|
}
|
|
}
|
|
for _, p := range provs {
|
|
emit(p, c)
|
|
}
|
|
continue
|
|
}
|
|
// Service-level consumer joins every provider of the service.
|
|
for _, p := range provBySvc[sk] {
|
|
emit(p, c)
|
|
}
|
|
}
|
|
|
|
// Orphan providers seek consumers (covers the registration-site
|
|
// provider whose consumers all exact-matched the IDL definition).
|
|
for _, p := range result.OrphanProviders {
|
|
if !isRPCFamily(p) {
|
|
continue
|
|
}
|
|
if _, done := joinedProv[matcherIdentity(p)]; done {
|
|
continue
|
|
}
|
|
svc, method := rpcServiceMethod(p)
|
|
if svc == "" {
|
|
continue
|
|
}
|
|
sk := svcKey{p.EffectiveWorkspace(), p.EffectiveProject(), svc}
|
|
if method != "" {
|
|
cons := consByMethod[methodKey{sk.ws, sk.proj, svc, method}]
|
|
if len(cons) == 0 {
|
|
for _, c := range consBySvc[sk] {
|
|
if _, cm := rpcServiceMethod(c); cm == "" {
|
|
cons = append(cons, c)
|
|
}
|
|
}
|
|
}
|
|
for _, c := range cons {
|
|
emit(p, c)
|
|
}
|
|
continue
|
|
}
|
|
for _, c := range consBySvc[sk] {
|
|
emit(p, c)
|
|
}
|
|
}
|
|
|
|
if len(joinedProv) > 0 {
|
|
kept := result.OrphanProviders[:0]
|
|
for _, p := range result.OrphanProviders {
|
|
if _, done := joinedProv[matcherIdentity(p)]; done {
|
|
continue
|
|
}
|
|
kept = append(kept, p)
|
|
}
|
|
result.OrphanProviders = kept
|
|
}
|
|
if len(joinedCons) > 0 {
|
|
kept := result.OrphanConsumers[:0]
|
|
for _, c := range result.OrphanConsumers {
|
|
if _, done := joinedCons[matcherIdentity(c)]; done {
|
|
continue
|
|
}
|
|
kept = append(kept, c)
|
|
}
|
|
result.OrphanConsumers = kept
|
|
}
|
|
}
|
|
|
|
// trpcRouterProcedure extracts the canonical join components of a tRPC
|
|
// contract: the router namespace and the procedure name, both
|
|
// lowercased. The procedure is the stable join key — client and server
|
|
// always agree on it — while the router is the part that drifts: the
|
|
// server names it after the variable the router object is assigned to
|
|
// (`export const userRouter = createTRPCRouter({...})`) and the client
|
|
// reaches it through a proxy chain whose first segment is spelled to
|
|
// taste (`trpc.api.getUser`). The router is returned only so the join
|
|
// can detect genuine ambiguity; it is deliberately NOT part of the key.
|
|
// Reads Meta["router"]/Meta["procedure"], falling back to parsing the
|
|
// `trpc::<router>.<procedure>` contract ID.
|
|
func trpcRouterProcedure(c Contract) (router, procedure string) {
|
|
if c.Meta != nil {
|
|
router, _ = c.Meta["router"].(string)
|
|
procedure, _ = c.Meta["procedure"].(string)
|
|
}
|
|
if router == "" || procedure == "" {
|
|
// ID shape: trpc::<router>.<procedure>. The router is a single
|
|
// identifier and the procedure an object key, so exactly one dot
|
|
// separates them; LastIndex keeps the procedure intact even if a
|
|
// future router form carried its own dots.
|
|
rest := strings.TrimPrefix(c.ID, "trpc::")
|
|
if dot := strings.LastIndex(rest, "."); dot >= 0 {
|
|
if router == "" {
|
|
router = rest[:dot]
|
|
}
|
|
if procedure == "" {
|
|
procedure = rest[dot+1:]
|
|
}
|
|
}
|
|
}
|
|
return strings.ToLower(router), strings.ToLower(procedure)
|
|
}
|
|
|
|
// joinTRPCCanonical pairs the tRPC orphans left over from exact-ID
|
|
// matching, within the same (workspace, project) boundary the exact
|
|
// pass uses. tRPC is its OWN cohort: this pass only ever touches
|
|
// ContractTRPC records, and the RPC-family pass (joinRPCCanonical /
|
|
// isRPCFamily) deliberately excludes tRPC, so a tRPC consumer can never
|
|
// pair a gRPC or Thrift provider — the two never share an index.
|
|
//
|
|
// The canonical key is the lowercased PROCEDURE name alone. The
|
|
// server's router variable and the client's proxy-chain namespace
|
|
// routinely disagree on spelling while the procedure name stays stable,
|
|
// so keying on the procedure (and dropping the router from the key)
|
|
// recovers a client call to its server procedure regardless of how the
|
|
// router was spelled or cased — mirroring how joinRPCCanonical strips a
|
|
// service's package qualifier and compares methods case-insensitively.
|
|
//
|
|
// Precision over recall: a procedure name that more than one distinct
|
|
// provider router offers within the boundary is genuinely ambiguous —
|
|
// the consumer's plain `trpc.<ns>.<proc>` chain can't say which router
|
|
// it meant — so such procedures are NOT joined and stay orphans. A
|
|
// procedure offered by exactly one provider router joins regardless of
|
|
// the router-namespace spelling the consumer used. Joined links group
|
|
// under the provider's contract ID (the server's spelling is
|
|
// authoritative) so bridge materialisation collapses every spelling of
|
|
// one server procedure into a single group.
|
|
func joinTRPCCanonical(result *MatchResult) {
|
|
type procKey struct{ ws, proj, proc string }
|
|
|
|
// Index every tRPC contract on BOTH sides of the result — matched
|
|
// and orphaned — so an orphan can join a side that already
|
|
// exact-matched a differently-spelled counterpart.
|
|
var allProviders, allConsumers []Contract
|
|
for _, m := range result.Matched {
|
|
allProviders = append(allProviders, m.Provider)
|
|
allConsumers = append(allConsumers, m.Consumer)
|
|
}
|
|
allProviders = append(allProviders, result.OrphanProviders...)
|
|
allConsumers = append(allConsumers, result.OrphanConsumers...)
|
|
|
|
provByProc := make(map[procKey][]Contract)
|
|
provRouters := make(map[procKey]map[string]struct{})
|
|
provSeen := make(map[string]struct{})
|
|
for _, p := range allProviders {
|
|
if p.Type != ContractTRPC {
|
|
continue
|
|
}
|
|
idKey := matcherIdentity(p)
|
|
if _, dup := provSeen[idKey]; dup {
|
|
continue
|
|
}
|
|
provSeen[idKey] = struct{}{}
|
|
router, proc := trpcRouterProcedure(p)
|
|
if proc == "" {
|
|
continue
|
|
}
|
|
pk := procKey{p.EffectiveWorkspace(), p.EffectiveProject(), proc}
|
|
provByProc[pk] = append(provByProc[pk], p)
|
|
if provRouters[pk] == nil {
|
|
provRouters[pk] = make(map[string]struct{})
|
|
}
|
|
provRouters[pk][router] = struct{}{}
|
|
}
|
|
|
|
consByProc := make(map[procKey][]Contract)
|
|
consSeen := make(map[string]struct{})
|
|
for _, c := range allConsumers {
|
|
if c.Type != ContractTRPC {
|
|
continue
|
|
}
|
|
idKey := matcherIdentity(c)
|
|
if _, dup := consSeen[idKey]; dup {
|
|
continue
|
|
}
|
|
consSeen[idKey] = struct{}{}
|
|
_, proc := trpcRouterProcedure(c)
|
|
if proc == "" {
|
|
continue
|
|
}
|
|
pk := procKey{c.EffectiveWorkspace(), c.EffectiveProject(), proc}
|
|
consByProc[pk] = append(consByProc[pk], c)
|
|
}
|
|
|
|
// A procedure offered by more than one distinct provider router is
|
|
// ambiguous on the procedure name alone — skip the whole procedure
|
|
// (both join directions) so an ambiguous name never mints a guess.
|
|
ambiguous := func(pk procKey) bool { return len(provRouters[pk]) > 1 }
|
|
|
|
joinedProv := make(map[string]struct{})
|
|
joinedCons := make(map[string]struct{})
|
|
linked := make(map[string]struct{})
|
|
emit := func(p, c Contract) {
|
|
lk := matcherIdentity(p) + "->" + matcherIdentity(c)
|
|
if _, dup := linked[lk]; dup {
|
|
return
|
|
}
|
|
linked[lk] = struct{}{}
|
|
result.Matched = append(result.Matched, CrossLink{
|
|
ContractID: p.ID,
|
|
Provider: p,
|
|
Consumer: c,
|
|
CrossRepo: p.RepoPrefix != c.RepoPrefix,
|
|
})
|
|
joinedProv[matcherIdentity(p)] = struct{}{}
|
|
joinedCons[matcherIdentity(c)] = struct{}{}
|
|
}
|
|
|
|
// Orphan consumers seek a provider of the same procedure.
|
|
for _, c := range result.OrphanConsumers {
|
|
if c.Type != ContractTRPC {
|
|
continue
|
|
}
|
|
_, proc := trpcRouterProcedure(c)
|
|
if proc == "" {
|
|
continue
|
|
}
|
|
pk := procKey{c.EffectiveWorkspace(), c.EffectiveProject(), proc}
|
|
if ambiguous(pk) {
|
|
continue
|
|
}
|
|
for _, p := range provByProc[pk] {
|
|
emit(p, c)
|
|
}
|
|
}
|
|
|
|
// Orphan providers seek consumers of the same procedure (covers a
|
|
// provider whose same-spelled consumers all exact-matched, leaving a
|
|
// differently-spelled consumer orphaned in another repo).
|
|
for _, p := range result.OrphanProviders {
|
|
if p.Type != ContractTRPC {
|
|
continue
|
|
}
|
|
if _, done := joinedProv[matcherIdentity(p)]; done {
|
|
continue
|
|
}
|
|
_, proc := trpcRouterProcedure(p)
|
|
if proc == "" {
|
|
continue
|
|
}
|
|
pk := procKey{p.EffectiveWorkspace(), p.EffectiveProject(), proc}
|
|
if ambiguous(pk) {
|
|
continue
|
|
}
|
|
for _, c := range consByProc[pk] {
|
|
emit(p, c)
|
|
}
|
|
}
|
|
|
|
if len(joinedProv) > 0 {
|
|
kept := result.OrphanProviders[:0]
|
|
for _, p := range result.OrphanProviders {
|
|
if _, done := joinedProv[matcherIdentity(p)]; done {
|
|
continue
|
|
}
|
|
kept = append(kept, p)
|
|
}
|
|
result.OrphanProviders = kept
|
|
}
|
|
if len(joinedCons) > 0 {
|
|
kept := result.OrphanConsumers[:0]
|
|
for _, c := range result.OrphanConsumers {
|
|
if _, done := joinedCons[matcherIdentity(c)]; done {
|
|
continue
|
|
}
|
|
kept = append(kept, c)
|
|
}
|
|
result.OrphanConsumers = kept
|
|
}
|
|
}
|