From 1469a6a7710bb02298ac1218097708a83a525f5c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sat, 22 Aug 2026 11:51:10 -0400 Subject: [PATCH] feat(composition): coherence gate for shared library closures (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pkg/composition, which resolves a consumer's transitive library-dependency closure and rejects version diamonds — a shared library required at more than one major. Independently generated SDKs that embed or import the same first-party contract fail to link when a consumer installs both at incompatible majors (duplicate proto registration panic in Go, descriptor-pool clash in Python). Catching the diamond at resolution time turns that runtime panic into a clear config error. Wire the gate into `sync library-dependencies`, which is where a service's closure is resolved for local development. Co-Authored-By: Claude Opus 4.8 --- cmd/sync/library_dependencies.go | 14 ++ pkg/composition/coherence.go | 189 ++++++++++++++++++++++++++ pkg/composition/coherence_test.go | 215 ++++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 pkg/composition/coherence.go create mode 100644 pkg/composition/coherence_test.go diff --git a/cmd/sync/library_dependencies.go b/cmd/sync/library_dependencies.go index 189e8b72..0a4817c5 100644 --- a/cmd/sync/library_dependencies.go +++ b/cmd/sync/library_dependencies.go @@ -5,6 +5,7 @@ import ( "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/composition" "github.com/codefly-dev/core/resources" "github.com/spf13/cobra" ) @@ -91,6 +92,19 @@ func syncLibraryDependencies() error { cli.Info(" - %s (%s) [%v]", dep.Name, dep.Version, dep.Languages) } + // A service that pulls several library SDKs shares first-party contracts + // through them. If the closure needs a shared library at two majors, the + // generated SDKs will not link once installed together, so reject the + // diamond here instead of letting it surface as a runtime panic. + closure, err := composition.NewResolver(workspace).Closure(ctx, svc.LibraryDependencies, + fmt.Sprintf("service %s/%s", moduleName, serviceName)) + if err != nil { + return fmt.Errorf("cannot resolve library closure: %w", err) + } + if err := closure.Validate(); err != nil { + return err + } + if err := resolver.SetupLocalDevelopment(ctx, svc); err != nil { return fmt.Errorf("failed to setup local development: %w", err) } diff --git a/pkg/composition/coherence.go b/pkg/composition/coherence.go new file mode 100644 index 00000000..1aaab378 --- /dev/null +++ b/pkg/composition/coherence.go @@ -0,0 +1,189 @@ +// Package composition resolves a consumer's transitive library-dependency +// closure and checks that it is coherent: every shared library resolves to a +// single major version. When two independently generated SDKs share a +// first-party contract (a common proto library) at incompatible majors, the +// consumer that installs both fails to link — two distinct Go types register +// the same proto file path and panic at init, Python hits a duplicate symbol +// in the descriptor pool, TS duplicates types. Catching the diamond here, at +// resolution time, turns that runtime panic into a clear configuration error. +package composition + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/Masterminds/semver" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/wool" +) + +// versionResolver resolves a library name and semver constraint to the +// concrete library and the version that satisfies it. +// *resources.LibraryResolver is the production implementation. +type versionResolver interface { + ResolveVersion(ctx context.Context, name, constraint string) (*resources.Library, string, error) +} + +// Resolver walks a consumer's transitive library-dependency graph and reports +// whether the resulting closure is coherent. +type Resolver struct { + resolve versionResolver +} + +// NewResolver builds a Resolver over the workspace's libraries. +func NewResolver(workspace *resources.Workspace) *Resolver { + return &Resolver{resolve: resources.NewLibraryResolver(workspace)} +} + +// Requirement is a single edge into a library within a closure: who asked for +// it, under what constraint, and the version that constraint resolved to. +type Requirement struct { + RequiredBy string + Constraint string + Resolved string +} + +// Closure is the transitive set of libraries a consumer pulls in, keyed by +// library name, with every requirement that referenced each one. +type Closure struct { + Requirements map[string][]Requirement +} + +// Closure resolves the transitive library closure rooted at deps. rootLabel +// names the consumer (e.g. "service backend/api") for diagnostics. +func (r *Resolver) Closure(ctx context.Context, deps []*resources.LibraryDependency, rootLabel string) (*Closure, error) { + w := wool.Get(ctx).In("composition.Resolver.Closure") + closure := &Closure{Requirements: map[string][]Requirement{}} + + type edge struct { + name string + constraint string + requiredBy string + } + var queue []edge + for _, dep := range deps { + if dep == nil || dep.Name == "" { + continue + } + queue = append(queue, edge{name: dep.Name, constraint: dep.Version, requiredBy: rootLabel}) + } + + // A library reachable through several paths is resolved (and recorded) on + // each path so every constraint on it is captured, but its own + // dependencies are only expanded once — that terminates on cycles too. + expanded := map[string]bool{} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + lib, resolved, err := r.resolve.ResolveVersion(ctx, current.name, current.constraint) + if err != nil { + return nil, w.Wrapf(err, "cannot resolve library %s (%s) required by %s", current.name, current.constraint, current.requiredBy) + } + closure.Requirements[current.name] = append(closure.Requirements[current.name], Requirement{ + RequiredBy: current.requiredBy, + Constraint: current.constraint, + Resolved: resolved, + }) + + if expanded[current.name] { + continue + } + expanded[current.name] = true + for _, dep := range lib.LibraryDeps { + if dep == nil || dep.Name == "" { + continue + } + queue = append(queue, edge{name: dep.Name, constraint: dep.Version, requiredBy: "library " + lib.Name}) + } + } + return closure, nil +} + +// MajorRequirements groups the requirements on a library that resolved to the +// same major version. +type MajorRequirements struct { + Major int64 + Requirements []Requirement +} + +// Violation is a library that a closure requires at more than one major +// version — a diamond that cannot be satisfied by a single installed copy. +type Violation struct { + Library string + Majors []MajorRequirements +} + +// Violations returns every shared library the closure requires at more than +// one major version, sorted by library name. +func (c *Closure) Violations() ([]Violation, error) { + names := make([]string, 0, len(c.Requirements)) + for name := range c.Requirements { + names = append(names, name) + } + sort.Strings(names) + + var violations []Violation + for _, name := range names { + byMajor := map[int64][]Requirement{} + for _, req := range c.Requirements[name] { + v, err := semver.NewVersion(req.Resolved) + if err != nil { + return nil, fmt.Errorf("library %s resolved to invalid version %q: %w", name, req.Resolved, err) + } + byMajor[v.Major()] = append(byMajor[v.Major()], req) + } + if len(byMajor) <= 1 { + continue + } + + majors := make([]int64, 0, len(byMajor)) + for major := range byMajor { + majors = append(majors, major) + } + sort.Slice(majors, func(i, j int) bool { return majors[i] < majors[j] }) + + violation := Violation{Library: name} + for _, major := range majors { + violation.Majors = append(violation.Majors, MajorRequirements{Major: major, Requirements: byMajor[major]}) + } + violations = append(violations, violation) + } + return violations, nil +} + +// Validate returns a coherence error if any shared library is required at more +// than one major. The error names each diamond and the requirements on each +// side so the consumer can pin a single major. +func (c *Closure) Validate() error { + violations, err := c.Violations() + if err != nil { + return err + } + if len(violations) == 0 { + return nil + } + + var b strings.Builder + fmt.Fprintf(&b, "incoherent library closure: %d shared %s required at multiple majors", + len(violations), plural(len(violations), "library", "libraries")) + for _, violation := range violations { + fmt.Fprintf(&b, "\n %s:", violation.Library) + for _, group := range violation.Majors { + for _, req := range group.Requirements { + fmt.Fprintf(&b, "\n v%d (%s -> %s) required by %s", + group.Major, req.Constraint, req.Resolved, req.RequiredBy) + } + } + } + return fmt.Errorf("%s", b.String()) +} + +func plural(n int, singular, plural string) string { + if n == 1 { + return singular + } + return plural +} diff --git a/pkg/composition/coherence_test.go b/pkg/composition/coherence_test.go new file mode 100644 index 00000000..e448a4c8 --- /dev/null +++ b/pkg/composition/coherence_test.go @@ -0,0 +1,215 @@ +package composition + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/resources" +) + +// fakeResolver resolves names+constraints to preset versions, driving the +// closure walk from constructed libraries. It lets the coherence logic be +// tested against true multi-major diamonds without git-tagged fixtures. +type fakeResolver struct { + libs map[string]*resources.Library + versions map[string]string // "name@constraint" -> resolved version +} + +func (f *fakeResolver) ResolveVersion(_ context.Context, name, constraint string) (*resources.Library, string, error) { + lib, ok := f.libs[name] + if !ok { + return nil, "", fmt.Errorf("unknown library %s", name) + } + resolved, ok := f.versions[name+"@"+constraint] + if !ok { + return nil, "", fmt.Errorf("no version of %s satisfies %s", name, constraint) + } + return lib, resolved, nil +} + +func lib(name string, deps ...*resources.LibraryReference) *resources.Library { + return &resources.Library{Name: name, LibraryDeps: deps} +} + +func ref(name, version string) *resources.LibraryReference { + return &resources.LibraryReference{Name: name, Version: version} +} + +func dep(name, version string) *resources.LibraryDependency { + return &resources.LibraryDependency{Name: name, Version: version, Languages: []string{"go"}} +} + +func TestClosureCoherentSharedLibrary(t *testing.T) { + fake := &fakeResolver{ + libs: map[string]*resources.Library{ + "accounts-sdk": lib("accounts-sdk", ref("common", "^1.0.0")), + "billing-sdk": lib("billing-sdk", ref("common", "^1.0.0")), + "common": lib("common"), + }, + versions: map[string]string{ + "accounts-sdk@^1.0.0": "1.4.0", + "billing-sdk@^1.0.0": "1.1.0", + "common@^1.0.0": "1.2.0", + }, + } + r := &Resolver{resolve: fake} + + closure, err := r.Closure(context.Background(), []*resources.LibraryDependency{ + dep("accounts-sdk", "^1.0.0"), + dep("billing-sdk", "^1.0.0"), + }, "solution accounts+billing") + if err != nil { + t.Fatalf("closure: %v", err) + } + + // common is reached through both SDKs, so both requirements are recorded. + if got := len(closure.Requirements["common"]); got != 2 { + t.Fatalf("common requirements = %d, want 2", got) + } + if err := closure.Validate(); err != nil { + t.Fatalf("coherent closure rejected: %v", err) + } +} + +func TestClosureDiamondAcrossMajors(t *testing.T) { + fake := &fakeResolver{ + libs: map[string]*resources.Library{ + "accounts-sdk": lib("accounts-sdk", ref("common", "^1.0.0")), + "billing-sdk": lib("billing-sdk", ref("common", "^2.0.0")), + "common": lib("common"), + }, + versions: map[string]string{ + "accounts-sdk@^1.0.0": "1.4.0", + "billing-sdk@^1.0.0": "1.1.0", + "common@^1.0.0": "1.2.0", + "common@^2.0.0": "2.1.0", + }, + } + r := &Resolver{resolve: fake} + + closure, err := r.Closure(context.Background(), []*resources.LibraryDependency{ + dep("accounts-sdk", "^1.0.0"), + dep("billing-sdk", "^1.0.0"), + }, "solution accounts+billing") + if err != nil { + t.Fatalf("closure: %v", err) + } + + violations, err := closure.Violations() + if err != nil { + t.Fatalf("violations: %v", err) + } + if len(violations) != 1 || violations[0].Library != "common" { + t.Fatalf("violations = %#v, want one for common", violations) + } + if len(violations[0].Majors) != 2 { + t.Fatalf("common majors = %d, want 2", len(violations[0].Majors)) + } + + err = closure.Validate() + if err == nil { + t.Fatal("diamond closure was accepted") + } + for _, want := range []string{"common", "v1", "v2", "accounts-sdk", "billing-sdk"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q missing %q", err.Error(), want) + } + } +} + +func TestClosureUnresolvableDependencyErrors(t *testing.T) { + fake := &fakeResolver{ + libs: map[string]*resources.Library{"accounts-sdk": lib("accounts-sdk", ref("common", "^9.0.0"))}, + versions: map[string]string{"accounts-sdk@^1.0.0": "1.0.0"}, + } + r := &Resolver{resolve: fake} + + _, err := r.Closure(context.Background(), []*resources.LibraryDependency{dep("accounts-sdk", "^1.0.0")}, "solution") + if err == nil { + t.Fatal("expected error resolving unsatisfiable transitive dependency") + } + if !strings.Contains(err.Error(), "common") { + t.Fatalf("error %q missing offending library", err.Error()) + } +} + +func TestClosureTerminatesOnCycle(t *testing.T) { + fake := &fakeResolver{ + libs: map[string]*resources.Library{ + "a": lib("a", ref("b", "^1.0.0")), + "b": lib("b", ref("a", "^1.0.0")), + }, + versions: map[string]string{"a@^1.0.0": "1.0.0", "b@^1.0.0": "1.0.0"}, + } + r := &Resolver{resolve: fake} + + closure, err := r.Closure(context.Background(), []*resources.LibraryDependency{dep("a", "^1.0.0")}, "solution") + if err != nil { + t.Fatalf("closure: %v", err) + } + if err := closure.Validate(); err != nil { + t.Fatalf("cyclic-but-coherent closure rejected: %v", err) + } +} + +// TestClosureAgainstLocalWorkspace exercises the real LibraryResolver over an +// on-disk workspace, covering the coherent path and an unsatisfiable one. +func TestClosureAgainstLocalWorkspace(t *testing.T) { + root := t.TempDir() + writeFixture(t, filepath.Join(root, resources.WorkspaceConfigurationName), "name: composition-fixture\nlayout: flat\n") + + writeLibrary(t, root, "common", "1.2.0") + writeLibrary(t, root, "accounts-sdk", "1.0.0", ref("common", "^1.0.0")) + writeLibrary(t, root, "billing-sdk", "1.0.0", ref("common", "^1.0.0")) + writeLibrary(t, root, "legacy-sdk", "1.0.0", ref("common", "^0.1.0")) + + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), root) + if err != nil { + t.Fatalf("load workspace: %v", err) + } + r := NewResolver(workspace) + + closure, err := r.Closure(context.Background(), []*resources.LibraryDependency{ + dep("accounts-sdk", "^1.0.0"), + dep("billing-sdk", "^1.0.0"), + }, "solution") + if err != nil { + t.Fatalf("closure: %v", err) + } + if err := closure.Validate(); err != nil { + t.Fatalf("coherent local closure rejected: %v", err) + } + + if _, err := r.Closure(context.Background(), []*resources.LibraryDependency{ + dep("legacy-sdk", "^1.0.0"), + }, "solution"); err == nil { + t.Fatal("expected error: legacy-sdk needs common ^0.1.0 but only 1.2.0 exists") + } +} + +func writeLibrary(t *testing.T, root, name, version string, deps ...*resources.LibraryReference) { + t.Helper() + var b strings.Builder + fmt.Fprintf(&b, "kind: library\nname: %s\nversion: %s\nlanguages:\n - name: go\n agent: \"\"\n path: go/\n exports: [example/%s]\n", name, version, name) + if len(deps) > 0 { + b.WriteString("library-dependencies:\n") + for _, d := range deps { + fmt.Fprintf(&b, " - name: %s\n version: %s\n", d.Name, d.Version) + } + } + writeFixture(t, filepath.Join(root, "libraries", name, resources.LibraryConfigurationName), b.String()) +} + +func writeFixture(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +}