From 2287c5cbd5804dbd60985594870b235a909cf0db Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 18 Aug 2026 21:09:03 -0400 Subject: [PATCH] feat: automate source plugin pin promotions --- cmd/agent.go | 1 + cmd/agents/promote_source.go | 256 +++++++++++++++++++++++++ cmd/agents/promote_source_test.go | 208 ++++++++++++++++++++ cmd/agents/versions.go | 43 ++++- cmd/agents/versions_test.go | 31 +++ cmd/test/source.go | 79 +++++++- cmd/test/source_test.go | 119 ++++++++++++ pkg/sourceworkspace/compatibility.json | 74 +++++++ pkg/sourceworkspace/roster.go | 245 +++++++++++++++++++++++ pkg/sourceworkspace/roster_test.go | 116 +++++++++++ pkg/sourceworkspace/source.go | 92 +-------- pkg/sourceworkspace/source_test.go | 4 +- 12 files changed, 1168 insertions(+), 100 deletions(-) create mode 100644 cmd/agents/promote_source.go create mode 100644 cmd/agents/promote_source_test.go create mode 100644 cmd/test/source_test.go create mode 100644 pkg/sourceworkspace/compatibility.json create mode 100644 pkg/sourceworkspace/roster.go create mode 100644 pkg/sourceworkspace/roster_test.go diff --git a/cmd/agent.go b/cmd/agent.go index f39984b1..8717f1c4 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -20,4 +20,5 @@ func init() { AgentCmd.AddCommand(agents.InstallCmd) AgentCmd.AddCommand(agents.VersionsCmd) AgentCmd.AddCommand(agents.ListCmd) + AgentCmd.AddCommand(agents.PromoteSourceCmd) } diff --git a/cmd/agents/promote_source.go b/cmd/agents/promote_source.go new file mode 100644 index 00000000..5b903dc0 --- /dev/null +++ b/cmd/agents/promote_source.go @@ -0,0 +1,256 @@ +package agents + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/blang/semver" + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/sourceworkspace" + "github.com/codefly-dev/core/resources" + "github.com/spf13/cobra" +) + +var ( + sourcePromotionCLIDir string + sourcePromotionFixtures []string +) + +const ( + latestAgentVersion = "latest" + fallbackMarker = "fallback" +) + +var PromoteSourceCmd = &cobra.Command{ + Use: "promote-source ", + Short: "Qualify and generate a source-workspace compatibility pin change", + Long: `Qualify one released source plugin against every marker it owns, then +update only the CLI compatibility roster. Each fixture is exercised through +codefly test source with the exact candidate artifact in an isolated cache. +The generated pin remains an ordinary reviewable CLI change.`, + Example: ` codefly agent promote-source codefly.dev/go:0.0.37 \ + --cli-dir ../cli --fixture go.mod=./test-fixtures/go`, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + ctx, stop := common.SignalContext(ctx) + defer stop() + + fixtures, err := parseSourcePromotionFixtures(sourcePromotionFixtures) + if err != nil { + return err + } + home, err := os.MkdirTemp("", "codefly-source-promotion-*") + if err != nil { + return fmt.Errorf("create isolated Codefly home: %w", err) + } + defer os.RemoveAll(home) + result, err := promoteSourcePlugin(ctx, sourcePromotionOptions{ + agentSpec: args[0], + cliDir: sourcePromotionCLIDir, + fixtures: fixtures, + }, func(ctx context.Context, agent *resources.Agent, marker, fixture string) error { + return runSourceQualification(ctx, agent, marker, fixture, home) + }) + if err != nil { + return err + } + for _, proof := range result.proofs { + cli.Info("Qualified %s through %s (%s)", args[0], proof.marker, proof.fixture) + } + cli.Header(1, "Generated source-workspace promotion %s -> %s", result.previousVersion, result.version) + cli.Info("Updated only %s; review and commit this deterministic roster change", result.rosterPath) + return nil + }, +} + +type sourcePromotionOptions struct { + agentSpec string + cliDir string + fixtures map[string]string +} + +type sourcePromotionProof struct { + marker string + fixture string +} + +type sourcePromotionResult struct { + rosterPath string + previousVersion string + version string + proofs []sourcePromotionProof +} + +type sourceQualificationRunner func(context.Context, *resources.Agent, string, string) error + +func parseSourcePromotionFixtures(values []string) (map[string]string, error) { + fixtures := make(map[string]string, len(values)) + for _, value := range values { + marker, dir, ok := strings.Cut(value, "=") + marker = strings.TrimSpace(marker) + dir = strings.TrimSpace(dir) + if !ok || marker == "" || dir == "" { + return nil, fmt.Errorf("source promotion fixture %q must be marker=directory", value) + } + if _, exists := fixtures[marker]; exists { + return nil, fmt.Errorf("source promotion fixture repeats marker %q", marker) + } + fixtures[marker] = dir + } + return fixtures, nil +} + +func promoteSourcePlugin(ctx context.Context, options sourcePromotionOptions, qualify sourceQualificationRunner) (sourcePromotionResult, error) { + if !strings.Contains(options.agentSpec, ":") { + return sourcePromotionResult{}, fmt.Errorf("source promotion agent must include an exact version") + } + agent, err := resources.ParseAgent(ctx, resources.ServiceAgent, options.agentSpec) + if err != nil { + return sourcePromotionResult{}, fmt.Errorf("invalid source promotion agent: %w", err) + } + if agent.Version == latestAgentVersion || strings.HasPrefix(agent.Version, "v") { + return sourcePromotionResult{}, fmt.Errorf("source promotion agent must use a canonical exact version") + } + candidate, err := semver.Parse(agent.Version) + if err != nil { + return sourcePromotionResult{}, fmt.Errorf("source promotion agent has invalid version %q", agent.Version) + } + + cliDir, err := filepath.Abs(options.cliDir) + if err != nil { + return sourcePromotionResult{}, fmt.Errorf("resolve CLI directory: %w", err) + } + rosterPath := filepath.Join(cliDir, filepath.FromSlash(sourceworkspace.CompatibilityRosterRelativePath)) + roster, err := sourceworkspace.LoadCompatibilityRoster(rosterPath) + if err != nil { + return sourcePromotionResult{}, err + } + pluginIndex := -1 + for i, plugin := range roster.Plugins { + if plugin.Publisher == agent.Publisher && plugin.Name == agent.Name { + pluginIndex = i + break + } + } + if pluginIndex < 0 { + return sourcePromotionResult{}, fmt.Errorf("agent %s/%s is not in the source-workspace compatibility roster", agent.Publisher, agent.Name) + } + plugin := roster.Plugins[pluginIndex] + pinned, err := semver.Parse(plugin.Version) + if err != nil { + return sourcePromotionResult{}, err + } + if !candidate.GT(pinned) { + return sourcePromotionResult{}, fmt.Errorf("source promotion version %s must be newer than pin %s", candidate, pinned) + } + + requiredMarkers := append([]string(nil), plugin.Markers...) + if len(requiredMarkers) == 0 { + requiredMarkers = []string{fallbackMarker} + } + if len(options.fixtures) != len(requiredMarkers) { + return sourcePromotionResult{}, fmt.Errorf("source promotion for %s/%s requires one --fixture for each marker: %s", + agent.Publisher, agent.Name, strings.Join(requiredMarkers, ", ")) + } + proofs := make([]sourcePromotionProof, 0, len(requiredMarkers)) + for _, marker := range requiredMarkers { + fixture, ok := options.fixtures[marker] + if !ok { + return sourcePromotionResult{}, fmt.Errorf("source promotion is missing --fixture %s=directory", marker) + } + absoluteFixture, err := filepath.Abs(fixture) + if err != nil { + return sourcePromotionResult{}, fmt.Errorf("resolve %s fixture: %w", marker, err) + } + info, err := os.Stat(absoluteFixture) + if err != nil || !info.IsDir() { + return sourcePromotionResult{}, fmt.Errorf("source promotion fixture for %s is not a directory: %s", marker, absoluteFixture) + } + if marker != fallbackMarker { + if _, markerErr := os.Stat(filepath.Join(absoluteFixture, marker)); markerErr != nil { + return sourcePromotionResult{}, fmt.Errorf("source promotion fixture %s does not contain marker %s: %w", absoluteFixture, marker, markerErr) + } + } + selected, evidence, err := roster.SelectPlugin(absoluteFixture) + if err != nil { + return sourcePromotionResult{}, fmt.Errorf("select source promotion fixture for %s: %w", marker, err) + } + if selected.Publisher != plugin.Publisher || selected.Name != plugin.Name || + evidence.String() != sourcePromotionEvidence(marker) { + return sourcePromotionResult{}, fmt.Errorf("source promotion fixture %s selects %s through %s, want %s/%s through %s", + absoluteFixture, selected.Identifier(), evidence, plugin.Publisher, plugin.Name, sourcePromotionEvidence(marker)) + } + if err := qualify(ctx, agent, marker, absoluteFixture); err != nil { + return sourcePromotionResult{}, fmt.Errorf("qualify %s through marker %s: %w", agent.Identifier(), marker, err) + } + proofs = append(proofs, sourcePromotionProof{marker: marker, fixture: absoluteFixture}) + } + + previousVersion := roster.Plugins[pluginIndex].Version + roster.Plugins[pluginIndex].Version = agent.Version + if err := sourceworkspace.WriteCompatibilityRoster(rosterPath, roster); err != nil { + return sourcePromotionResult{}, err + } + return sourcePromotionResult{ + rosterPath: rosterPath, + previousVersion: previousVersion, + version: agent.Version, + proofs: proofs, + }, nil +} + +func sourcePromotionEvidence(marker string) string { + if marker == fallbackMarker { + return marker + } + return "marker:" + marker +} + +func runSourceQualification(ctx context.Context, agent *resources.Agent, _ string, fixture, home string) error { + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve Codefly executable: %w", err) + } + command := exec.CommandContext(ctx, executable, + "--timestamps=false", + "--plugin-path", home, + "test", "source", + "--dir", fixture, + "--agent", agent.Identifier(), + "--qualification", + ) + command.Dir = fixture + command.Env = sourcePromotionEnvironment(home) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Run() +} + +func sourcePromotionEnvironment(home string) []string { + environment := make([]string, 0, len(os.Environ())+3) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if ok && (name == resources.CodeflyHomeEnv || name == "CODEFLY_AGENT_SOURCE" || name == "GOWORK" || name == "CI" || name == "CODEFLY_COLOR") { + continue + } + environment = append(environment, entry) + } + return append(environment, + resources.CodeflyHomeEnv+"="+home, + "GOWORK=off", + "CI=1", + "CODEFLY_COLOR=never", + ) +} + +func init() { + PromoteSourceCmd.Flags().StringVar(&sourcePromotionCLIDir, "cli-dir", ".", "Codefly CLI checkout whose compatibility roster will be updated") + PromoteSourceCmd.Flags().StringArrayVar(&sourcePromotionFixtures, "fixture", nil, "Qualified source marker and checkout as marker=directory (repeat for every owned marker)") +} diff --git a/cmd/agents/promote_source_test.go b/cmd/agents/promote_source_test.go new file mode 100644 index 00000000..d049da45 --- /dev/null +++ b/cmd/agents/promote_source_test.go @@ -0,0 +1,208 @@ +package agents + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/sourceworkspace" + "github.com/codefly-dev/core/resources" +) + +func fixturePromotionRoster(t *testing.T) string { + t.Helper() + cliDir := t.TempDir() + rosterPath := filepath.Join(cliDir, filepath.FromSlash(sourceworkspace.CompatibilityRosterRelativePath)) + if err := os.MkdirAll(filepath.Dir(rosterPath), 0o755); err != nil { + t.Fatal(err) + } + roster := sourceworkspace.CompatibilityRoster{ + SchemaVersion: 1, + Plugins: []sourceworkspace.PluginCompatibility{ + { + Publisher: "codefly.dev", + Name: "python", + Version: "1.2.3", + Markers: []string{"pyproject.toml", "requirements.txt"}, + Extensions: []string{".py"}, + }, + {Publisher: "codefly.dev", Name: "generic", Version: "1.0.0", Fallback: true}, + }, + } + if err := sourceworkspace.WriteCompatibilityRoster(rosterPath, roster); err != nil { + t.Fatal(err) + } + return cliDir +} + +func markerFixture(t *testing.T, marker string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, marker), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestPromoteSourcePluginQualifiesEveryMarkerBeforeUpdatingRoster(t *testing.T) { + cliDir := fixturePromotionRoster(t) + rosterPath := filepath.Join(cliDir, filepath.FromSlash(sourceworkspace.CompatibilityRosterRelativePath)) + before, err := sourceworkspace.LoadCompatibilityRoster(rosterPath) + if err != nil { + t.Fatal(err) + } + fixtures := map[string]string{ + "pyproject.toml": markerFixture(t, "pyproject.toml"), + "requirements.txt": markerFixture(t, "requirements.txt"), + } + var calls []string + qualify := func(_ context.Context, agent *resources.Agent, marker, fixture string) error { + if agent.Identifier() != "codefly.dev/python:1.2.4" { + t.Fatalf("qualified agent = %s, want exact candidate", agent.Identifier()) + } + calls = append(calls, marker+"="+fixture) + return nil + } + + result, err := promoteSourcePlugin(context.Background(), sourcePromotionOptions{ + agentSpec: "codefly.dev/python:1.2.4", + cliDir: cliDir, + fixtures: fixtures, + }, qualify) + if err != nil { + t.Fatal(err) + } + wantCalls := []string{ + "pyproject.toml=" + fixtures["pyproject.toml"], + "requirements.txt=" + fixtures["requirements.txt"], + } + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("qualification calls = %v, want %v", calls, wantCalls) + } + if result.previousVersion != "1.2.3" || result.version != "1.2.4" || len(result.proofs) != 2 { + t.Fatalf("promotion result = %+v", result) + } + roster, err := sourceworkspace.LoadCompatibilityRoster(result.rosterPath) + if err != nil { + t.Fatal(err) + } + plugin, ok := roster.Plugin("codefly.dev", "python") + if !ok || plugin.Version != "1.2.4" { + t.Fatalf("promoted plugin = %+v, found = %v", plugin, ok) + } + + selected, _, err := roster.SelectPlugin(fixtures["pyproject.toml"]) + if err != nil { + t.Fatal(err) + } + if selected.Version != "1.2.4" { + t.Fatalf("source checkout launches %s, want promoted 1.2.4", selected.Version) + } + before.Plugins[0].Version = "1.2.4" + if !reflect.DeepEqual(roster, before) { + t.Fatalf("promotion changed fields beyond the target pin:\n before=%+v\n after=%+v", before, roster) + } +} + +func TestPromoteSourcePluginDoesNotUpdateRosterWhenQualificationFails(t *testing.T) { + cliDir := fixturePromotionRoster(t) + fixtures := map[string]string{ + "pyproject.toml": markerFixture(t, "pyproject.toml"), + "requirements.txt": markerFixture(t, "requirements.txt"), + } + qualificationFailure := errors.New("capability handshake failed") + _, err := promoteSourcePlugin(context.Background(), sourcePromotionOptions{ + agentSpec: "codefly.dev/python:1.2.4", + cliDir: cliDir, + fixtures: fixtures, + }, func(_ context.Context, _ *resources.Agent, marker, _ string) error { + if marker == "requirements.txt" { + return qualificationFailure + } + return nil + }) + if !errors.Is(err, qualificationFailure) { + t.Fatalf("promotion error = %v, want qualification failure", err) + } + roster, loadErr := sourceworkspace.LoadCompatibilityRoster(filepath.Join(cliDir, filepath.FromSlash(sourceworkspace.CompatibilityRosterRelativePath))) + if loadErr != nil { + t.Fatal(loadErr) + } + plugin, _ := roster.Plugin("codefly.dev", "python") + if plugin.Version != "1.2.3" { + t.Fatalf("pin changed after failed qualification: %s", plugin.Version) + } +} + +func TestPromoteSourcePluginRequiresEveryOwnedMarkerAndNewerVersion(t *testing.T) { + cliDir := fixturePromotionRoster(t) + fixture := markerFixture(t, "pyproject.toml") + never := func(context.Context, *resources.Agent, string, string) error { + t.Fatal("qualification should not run") + return nil + } + + _, err := promoteSourcePlugin(context.Background(), sourcePromotionOptions{ + agentSpec: "codefly.dev/python:1.2.4", + cliDir: cliDir, + fixtures: map[string]string{"pyproject.toml": fixture}, + }, never) + if err == nil { + t.Fatal("promotion with a missing marker fixture was accepted") + } + + _, err = promoteSourcePlugin(context.Background(), sourcePromotionOptions{ + agentSpec: "codefly.dev/python:1.2.3", + cliDir: cliDir, + fixtures: map[string]string{ + "pyproject.toml": fixture, + "requirements.txt": markerFixture(t, "requirements.txt"), + }, + }, never) + if err == nil { + t.Fatal("promotion to the current pin was accepted") + } +} + +func TestPromoteSourcePluginRequiresFixtureToSelectThroughNamedMarker(t *testing.T) { + cliDir := fixturePromotionRoster(t) + requirementsFixture := markerFixture(t, "requirements.txt") + if err := os.WriteFile(filepath.Join(requirementsFixture, "pyproject.toml"), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + qualified := 0 + _, err := promoteSourcePlugin(context.Background(), sourcePromotionOptions{ + agentSpec: "codefly.dev/python:1.2.4", + cliDir: cliDir, + fixtures: map[string]string{ + "pyproject.toml": markerFixture(t, "pyproject.toml"), + "requirements.txt": requirementsFixture, + }, + }, func(context.Context, *resources.Agent, string, string) error { + qualified++ + return nil + }) + if err == nil || !strings.Contains(err.Error(), "marker:pyproject.toml") { + t.Fatalf("promotion error = %v, want conflicting marker evidence", err) + } + if qualified != 1 { + t.Fatalf("qualified fixtures = %d, want only the valid first marker", qualified) + } +} + +func TestParseSourcePromotionFixturesRejectsDuplicates(t *testing.T) { + if _, err := parseSourcePromotionFixtures([]string{"go.mod=/one", "go.mod=/two"}); err == nil { + t.Fatal("duplicate fixture marker was accepted") + } + fixtures, err := parseSourcePromotionFixtures([]string{"go.mod=/fixture"}) + if err != nil { + t.Fatal(err) + } + if fixtures["go.mod"] != "/fixture" { + t.Fatalf("fixtures = %v", fixtures) + } +} diff --git a/cmd/agents/versions.go b/cmd/agents/versions.go index a116b6f4..eada77c5 100644 --- a/cmd/agents/versions.go +++ b/cmd/agents/versions.go @@ -16,6 +16,7 @@ import ( "github.com/blang/semver" "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/sourceworkspace" "github.com/codefly-dev/core/resources" "github.com/google/go-github/v89/github" "github.com/spf13/cobra" @@ -68,13 +69,21 @@ type versionEntry struct { } type inventory struct { - Agent string `json:"agent"` - CIPlatform string `json:"ci_platform"` - OCIConfigured bool `json:"oci_configured"` - Versions []versionEntry `json:"versions"` - Pinned []string `json:"pinned,omitempty"` - LatestTag string `json:"latest_tag,omitempty"` - LatestResolvable string `json:"latest_resolvable,omitempty"` + Agent string `json:"agent"` + CIPlatform string `json:"ci_platform"` + OCIConfigured bool `json:"oci_configured"` + Versions []versionEntry `json:"versions"` + Pinned []string `json:"pinned,omitempty"` + LatestTag string `json:"latest_tag,omitempty"` + LatestResolvable string `json:"latest_resolvable,omitempty"` + SourceWorkspace *sourceWorkspaceVersion `json:"source_workspace,omitempty"` +} + +type sourceWorkspaceVersion struct { + WillLaunch string `json:"will_launch"` + Markers []string `json:"markers,omitempty"` + PromotionCandidate string `json:"promotion_candidate,omitempty"` + Stale bool `json:"stale"` } func (inv inventory) versionResolvable(version string) bool { @@ -297,6 +306,18 @@ func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, if latestResolvable != nil { inv.LatestResolvable = latestResolvable.String() } + if plugin, ok := sourceworkspace.PinnedPlugin(agent.Publisher, agent.Name); ok { + status := &sourceWorkspaceVersion{ + WillLaunch: plugin.Version, + Markers: append([]string(nil), plugin.Markers...), + } + pinned, pinErr := semver.Parse(plugin.Version) + if pinErr == nil && latestResolvable != nil && latestResolvable.GT(pinned) { + status.PromotionCandidate = latestResolvable.String() + status.Stale = true + } + inv.SourceWorkspace = status + } return inv } @@ -613,6 +634,14 @@ func renderInventory(inv inventory) { } fmt.Printf("latest tag -> %s\n", dashIfEmpty(inv.LatestTag)) fmt.Printf("latest resolvable -> %s\n", dashIfEmpty(inv.LatestResolvable)) + if source := inv.SourceWorkspace; source != nil { + fmt.Printf("source checkout -> %s (this CLI's compatibility pin)\n", source.WillLaunch) + if source.Stale { + fmt.Printf("promotion candidate -> %s\n", source.PromotionCandidate) + fmt.Printf(" warning: source-workspace pin %s is stale; qualified release %s awaits exact CLI qualification and review\n", + source.WillLaunch, source.PromotionCandidate) + } + } if inv.LatestTag != "" && !inv.versionResolvable(inv.LatestTag) { fmt.Printf(" warning: latest tag %s has no downloadable artifact\n", inv.LatestTag) } diff --git a/cmd/agents/versions_test.go b/cmd/agents/versions_test.go index 6b0e47ba..b01034cf 100644 --- a/cmd/agents/versions_test.go +++ b/cmd/agents/versions_test.go @@ -85,6 +85,37 @@ func TestBuildInventoryLatestTagBeatsLatestResolvable(t *testing.T) { } } +func TestBuildInventoryDistinguishesSourcePinFromPromotionCandidate(t *testing.T) { + agent := &resources.Agent{ + Kind: resources.ServiceAgent, + Publisher: "codefly.dev", + Name: "python", + Version: "latest", + } + releases := []releaseInfo{ + {version: "0.0.52", platforms: []string{ciPlatform}}, + {version: "0.0.53", platforms: []string{ciPlatform}}, + } + + inv := buildInventory(agent, releases, []string{"0.0.52", "0.0.53"}, nil, nil, nil, false) + + if inv.LatestResolvable != "0.0.53" { + t.Fatalf("latest resolvable = %q, want 0.0.53", inv.LatestResolvable) + } + if inv.SourceWorkspace == nil { + t.Fatal("source-workspace status missing") + } + if inv.SourceWorkspace.WillLaunch != "0.0.52" { + t.Fatalf("source checkout version = %q, want pinned 0.0.52", inv.SourceWorkspace.WillLaunch) + } + if !inv.SourceWorkspace.Stale || inv.SourceWorkspace.PromotionCandidate != "0.0.53" { + t.Fatalf("source-workspace status = %+v, want stale promotion candidate 0.0.53", inv.SourceWorkspace) + } + if len(inv.SourceWorkspace.Markers) == 0 { + t.Fatal("source-workspace marker families missing") + } +} + func TestBuildInventorySortsDescending(t *testing.T) { tags := []string{"0.0.56", "0.0.74", "0.0.73"} inv := buildInventory(redisAgent(), nil, tags, nil, nil, nil, false) diff --git a/cmd/test/source.go b/cmd/test/source.go index d3c97ab8..d6a0bd8c 100644 --- a/cmd/test/source.go +++ b/cmd/test/source.go @@ -5,16 +5,23 @@ import ( "errors" "fmt" "os" + "strings" "time" + "github.com/blang/semver" "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/cli/pkg/sourceworkspace" + "github.com/codefly-dev/core/agents/manager" + agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0" + codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" + toolingv0 "github.com/codefly-dev/core/generated/go/codefly/services/tooling/v0" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/services" "github.com/spf13/cobra" + "google.golang.org/grpc" ) var ( @@ -27,6 +34,8 @@ var ( sourceVerbose bool sourceRace bool sourceCoverage bool + sourceAgent string + sourceQualification bool ) // SourceCmd validates an arbitrary checkout through the same Runtime.Test RPC @@ -52,11 +61,19 @@ var SourceCmd = &cobra.Command{ return fmt.Errorf("resolve source directory: %w", err) } } - prepared, err := sourceworkspace.Prepare(ctx, dir) + prepared, err := prepareSourceWorkspace(ctx, dir, sourceAgent) if err != nil { return err } defer prepared.Close() + if sourceQualification { + if sourceAgent == "" { + return fmt.Errorf("source qualification requires an exact --agent") + } + if err := verifySourceCapabilityHandshake(ctx, prepared.Service.Agent); err != nil { + return err + } + } request := &runtimev0.TestRequest{ Target: sourceTarget, @@ -97,6 +114,64 @@ var SourceCmd = &cobra.Command{ }, } +func prepareSourceWorkspace(ctx context.Context, dir, agentSpec string) (*sourceworkspace.Prepared, error) { + if agentSpec == "" { + return sourceworkspace.Prepare(ctx, dir) + } + if !strings.Contains(agentSpec, ":") { + return nil, fmt.Errorf("source agent must include an exact version") + } + agent, err := resources.ParseAgent(ctx, resources.ServiceAgent, agentSpec) + if err != nil { + return nil, fmt.Errorf("invalid source agent: %w", err) + } + if agent.Version == "latest" { + return nil, fmt.Errorf("source agent must use an exact version, not latest") + } + if _, err := semver.Parse(strings.TrimPrefix(agent.Version, "v")); err != nil || strings.HasPrefix(agent.Version, "v") { + return nil, fmt.Errorf("source agent version %q is not canonical semantic version", agent.Version) + } + return sourceworkspace.PrepareWithAgent(ctx, dir, agent) +} + +func verifySourceCapabilityHandshake(ctx context.Context, agent *resources.Agent) error { + connection, err := manager.Load(ctx, agent, manager.WithoutSandbox(), manager.WithoutPrincipal()) + if err != nil { + return fmt.Errorf("load exact source agent %s: %w", agent.Identifier(), err) + } + defer connection.Close() + return verifySourceCapabilityClients(ctx, agent, connection.GRPCConn()) +} + +func verifySourceCapabilityClients(ctx context.Context, agent *resources.Agent, connection grpc.ClientConnInterface) error { + info, err := agentv0.NewAgentClient(connection).GetAgentInformation(ctx, &agentv0.AgentInformationRequest{}) + if err != nil { + return fmt.Errorf("source agent handshake: %w", err) + } + runtimeAdvertised := false + for _, capability := range info.GetCapabilities() { + if capability.GetType() == agentv0.Capability_RUNTIME { + runtimeAdvertised = true + break + } + } + if !runtimeAdvertised { + return fmt.Errorf("source agent %s does not advertise Runtime capability", agent.Identifier()) + } + + codeClient := codev0.NewCodeClient(connection) + if _, err := codeClient.Execute(ctx, &codev0.CodeRequest{ + Operation: &codev0.CodeRequest_GetProjectInfo{GetProjectInfo: &codev0.GetProjectInfoRequest{}}, + }); err != nil { + return fmt.Errorf("source agent %s Code capability handshake: %w", agent.Identifier(), err) + } + toolingClient := toolingv0.NewToolingClient(connection) + if _, err := toolingClient.GetProjectInfo(ctx, &toolingv0.GetProjectInfoRequest{}); err != nil { + return fmt.Errorf("source agent %s Tooling capability handshake: %w", agent.Identifier(), err) + } + return nil +} + func initSourceTest(ctx context.Context, prepared *sourceworkspace.Prepared, request *runtimev0.TestRequest) (*orchestration.Flow, error) { if err := resources.ValidateRuntimeContext(sourceRuntimeContext); err != nil { return nil, fmt.Errorf("invalid runtime context: %w", err) @@ -131,4 +206,6 @@ func init() { SourceCmd.Flags().BoolVarP(&sourceVerbose, "verbose", "v", false, "Verbose test output") SourceCmd.Flags().BoolVar(&sourceRace, "race", false, "Enable plugin-defined race checking") SourceCmd.Flags().BoolVar(&sourceCoverage, "coverage", false, "Enable plugin-defined coverage") + SourceCmd.Flags().StringVar(&sourceAgent, "agent", "", "Use an exact publisher/name:version instead of the compatibility pin") + SourceCmd.Flags().BoolVar(&sourceQualification, "qualification", false, "Assert the exact agent's Runtime, Code, and Tooling handshake") } diff --git a/cmd/test/source_test.go b/cmd/test/source_test.go new file mode 100644 index 00000000..43b76925 --- /dev/null +++ b/cmd/test/source_test.go @@ -0,0 +1,119 @@ +package test + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" + + agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0" + codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" + toolingv0 "github.com/codefly-dev/core/generated/go/codefly/services/tooling/v0" + "github.com/codefly-dev/core/resources" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +type sourceHandshakeAgent struct { + agentv0.UnimplementedAgentServer + runtime bool +} + +func (s sourceHandshakeAgent) GetAgentInformation(context.Context, *agentv0.AgentInformationRequest) (*agentv0.AgentInformation, error) { + info := &agentv0.AgentInformation{} + if s.runtime { + info.Capabilities = []*agentv0.Capability{{Type: agentv0.Capability_RUNTIME}} + } + return info, nil +} + +type sourceHandshakeCode struct { + codev0.UnimplementedCodeServer +} + +func (sourceHandshakeCode) Execute(context.Context, *codev0.CodeRequest) (*codev0.CodeResponse, error) { + return &codev0.CodeResponse{}, nil +} + +type sourceHandshakeTooling struct { + toolingv0.UnimplementedToolingServer +} + +func (sourceHandshakeTooling) GetProjectInfo(context.Context, *toolingv0.GetProjectInfoRequest) (*toolingv0.GetProjectInfoResponse, error) { + return &toolingv0.GetProjectInfoResponse{}, nil +} + +func TestPrepareSourceWorkspaceUsesExactAgentOverride(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + prepared, err := prepareSourceWorkspace(context.Background(), dir, "codefly.dev/go:9.9.9") + if err != nil { + t.Fatal(err) + } + defer prepared.Close() + if got := prepared.Service.Agent; got.Publisher != "codefly.dev" || got.Name != "go" || got.Version != "9.9.9" { + t.Fatalf("source agent = %+v, want exact codefly.dev/go:9.9.9", got) + } +} + +func TestPrepareSourceWorkspaceRejectsFloatingAgentOverride(t *testing.T) { + for _, spec := range []string{"codefly.dev/go", "codefly.dev/go:latest", "codefly.dev/go:v1.2.3"} { + t.Run(strings.ReplaceAll(spec, "/", "_"), func(t *testing.T) { + if _, err := prepareSourceWorkspace(context.Background(), t.TempDir(), spec); err == nil { + t.Fatalf("agent override %q was accepted", spec) + } + }) + } +} + +func TestVerifySourceCapabilityClientsRequiresRuntimeCodeAndTooling(t *testing.T) { + tests := []struct { + name string + runtime bool + registerCode bool + registerTooling bool + wantError string + }{ + {name: "complete handshake", runtime: true, registerCode: true, registerTooling: true}, + {name: "runtime missing", registerCode: true, registerTooling: true, wantError: "Runtime capability"}, + {name: "code missing", runtime: true, registerTooling: true, wantError: "Code capability handshake"}, + {name: "tooling missing", runtime: true, registerCode: true, wantError: "Tooling capability handshake"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + agentv0.RegisterAgentServer(server, sourceHandshakeAgent{runtime: test.runtime}) + if test.registerCode { + codev0.RegisterCodeServer(server, sourceHandshakeCode{}) + } + if test.registerTooling { + toolingv0.RegisterToolingServer(server, sourceHandshakeTooling{}) + } + go func() { _ = server.Serve(listener) }() + defer server.Stop() + + connection, err := grpc.NewClient("passthrough:///source-handshake", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + agent := &resources.Agent{Publisher: "codefly.dev", Name: "fixture", Version: "1.2.3"} + err = verifySourceCapabilityClients(context.Background(), agent, connection) + if test.wantError == "" && err != nil { + t.Fatal(err) + } + if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) { + t.Fatalf("handshake error = %v, want %q", err, test.wantError) + } + }) + } +} diff --git a/pkg/sourceworkspace/compatibility.json b/pkg/sourceworkspace/compatibility.json new file mode 100644 index 00000000..91140282 --- /dev/null +++ b/pkg/sourceworkspace/compatibility.json @@ -0,0 +1,74 @@ +{ + "schema_version": 1, + "plugins": [ + { + "publisher": "codefly.dev", + "name": "go", + "version": "0.0.37", + "markers": [ + "go.mod" + ], + "extensions": [ + ".go" + ] + }, + { + "publisher": "codefly.dev", + "name": "python", + "version": "0.0.52", + "markers": [ + "pyproject.toml", + "uv.lock", + "setup.py", + "setup.cfg", + "requirements.in", + "requirements.txt" + ], + "extensions": [ + ".py" + ] + }, + { + "publisher": "codefly.dev", + "name": "nextjs", + "version": "0.0.141", + "markers": [ + "package.json" + ], + "extensions": [ + ".js", + ".jsx", + ".ts", + ".tsx" + ] + }, + { + "publisher": "codefly.dev", + "name": "rust", + "version": "0.0.29", + "markers": [ + "Cargo.toml" + ], + "extensions": [ + ".rs" + ] + }, + { + "publisher": "codefly.dev", + "name": "swift", + "version": "0.0.16", + "markers": [ + "Package.swift" + ], + "extensions": [ + ".swift" + ] + }, + { + "publisher": "codefly.dev", + "name": "generic", + "version": "0.0.26", + "fallback": true + } + ] +} diff --git a/pkg/sourceworkspace/roster.go b/pkg/sourceworkspace/roster.go new file mode 100644 index 00000000..aee4cba0 --- /dev/null +++ b/pkg/sourceworkspace/roster.go @@ -0,0 +1,245 @@ +package sourceworkspace + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/blang/semver" + "github.com/codefly-dev/core/resources" +) + +const CompatibilityRosterRelativePath = "pkg/sourceworkspace/compatibility.json" + +//go:embed compatibility.json +var embeddedCompatibilityRoster []byte + +type CompatibilityRoster struct { + SchemaVersion int `json:"schema_version"` + Plugins []PluginCompatibility `json:"plugins"` +} + +type PluginCompatibility struct { + Publisher string `json:"publisher"` + Name string `json:"name"` + Version string `json:"version"` + Markers []string `json:"markers,omitempty"` + Extensions []string `json:"extensions,omitempty"` + Fallback bool `json:"fallback,omitempty"` +} + +type SelectionEvidence struct { + Kind string + Value string +} + +func (e SelectionEvidence) String() string { + if e.Value == "" { + return e.Kind + } + return e.Kind + ":" + e.Value +} + +var compatibilityRoster = mustParseCompatibilityRoster(embeddedCompatibilityRoster) + +var ( + GenericGoPluginVersion = mustPinnedVersion("codefly.dev", "go") + GenericPythonPluginVersion = mustPinnedVersion("codefly.dev", "python") + GenericPluginVersion = mustPinnedVersion("codefly.dev", "generic") + NodePluginVersion = mustPinnedVersion("codefly.dev", "nextjs") + RustPluginVersion = mustPinnedVersion("codefly.dev", "rust") + SwiftPluginVersion = mustPinnedVersion("codefly.dev", "swift") +) + +func mustParseCompatibilityRoster(payload []byte) CompatibilityRoster { + roster, err := ParseCompatibilityRoster(payload) + if err != nil { + panic(err) + } + return roster +} + +func mustPinnedVersion(publisher, name string) string { + plugin, ok := compatibilityRoster.Plugin(publisher, name) + if !ok { + panic(fmt.Sprintf("source-workspace compatibility roster has no %s/%s plugin", publisher, name)) + } + return plugin.Version +} + +func ParseCompatibilityRoster(payload []byte) (CompatibilityRoster, error) { + var roster CompatibilityRoster + if err := json.Unmarshal(payload, &roster); err != nil { + return CompatibilityRoster{}, fmt.Errorf("parse source-workspace compatibility roster: %w", err) + } + if err := roster.Validate(); err != nil { + return CompatibilityRoster{}, err + } + return roster, nil +} + +func LoadCompatibilityRoster(path string) (CompatibilityRoster, error) { + payload, err := os.ReadFile(path) + if err != nil { + return CompatibilityRoster{}, fmt.Errorf("read source-workspace compatibility roster: %w", err) + } + return ParseCompatibilityRoster(payload) +} + +func WriteCompatibilityRoster(path string, roster CompatibilityRoster) error { + if err := roster.Validate(); err != nil { + return err + } + payload, err := json.MarshalIndent(roster, "", " ") + if err != nil { + return fmt.Errorf("encode source-workspace compatibility roster: %w", err) + } + payload = append(payload, '\n') + if err := os.WriteFile(path, payload, 0o600); err != nil { + return fmt.Errorf("write source-workspace compatibility roster: %w", err) + } + return nil +} + +func Roster() CompatibilityRoster { + roster := compatibilityRoster + roster.Plugins = append([]PluginCompatibility(nil), compatibilityRoster.Plugins...) + for i := range roster.Plugins { + roster.Plugins[i].Markers = append([]string(nil), roster.Plugins[i].Markers...) + roster.Plugins[i].Extensions = append([]string(nil), roster.Plugins[i].Extensions...) + } + return roster +} + +func PinnedPlugin(publisher, name string) (PluginCompatibility, bool) { + return compatibilityRoster.Plugin(publisher, name) +} + +func (r CompatibilityRoster) Plugin(publisher, name string) (PluginCompatibility, bool) { + for _, plugin := range r.Plugins { + if plugin.Publisher == publisher && plugin.Name == name { + return plugin, true + } + } + return PluginCompatibility{}, false +} + +func (p *PluginCompatibility) Agent() *resources.Agent { + return &resources.Agent{ + Kind: resources.ServiceAgent, + Publisher: p.Publisher, + Name: p.Name, + Version: p.Version, + } +} + +func (r CompatibilityRoster) Validate() error { + if r.SchemaVersion != 1 { + return fmt.Errorf("source-workspace compatibility roster schema_version = %d, want 1", r.SchemaVersion) + } + if len(r.Plugins) == 0 { + return fmt.Errorf("source-workspace compatibility roster has no plugins") + } + agents := map[string]bool{} + markers := map[string]bool{} + extensions := map[string]bool{} + fallbacks := 0 + for i, plugin := range r.Plugins { + identity := plugin.Publisher + "/" + plugin.Name + if strings.TrimSpace(plugin.Publisher) == "" || strings.TrimSpace(plugin.Name) == "" { + return fmt.Errorf("source-workspace compatibility plugin %d must have publisher and name", i) + } + if agents[identity] { + return fmt.Errorf("source-workspace compatibility roster repeats plugin %s", identity) + } + agents[identity] = true + if _, err := semver.Parse(strings.TrimPrefix(plugin.Version, "v")); err != nil || strings.HasPrefix(plugin.Version, "v") { + return fmt.Errorf("source-workspace compatibility plugin %s has invalid exact version %q", identity, plugin.Version) + } + if plugin.Fallback { + fallbacks++ + if len(plugin.Markers) > 0 || len(plugin.Extensions) > 0 { + return fmt.Errorf("source-workspace fallback plugin %s cannot declare markers or extensions", identity) + } + } + for _, marker := range plugin.Markers { + if marker == "" || filepath.IsAbs(marker) || filepath.Clean(marker) != marker || strings.HasPrefix(marker, "..") { + return fmt.Errorf("source-workspace compatibility plugin %s has invalid marker %q", identity, marker) + } + if markers[marker] { + return fmt.Errorf("source-workspace compatibility roster repeats marker %q", marker) + } + markers[marker] = true + } + for _, extension := range plugin.Extensions { + if extension == "" || extension != strings.ToLower(extension) || !strings.HasPrefix(extension, ".") { + return fmt.Errorf("source-workspace compatibility plugin %s has invalid extension %q", identity, extension) + } + if extensions[extension] { + return fmt.Errorf("source-workspace compatibility roster repeats extension %q", extension) + } + extensions[extension] = true + } + } + if fallbacks != 1 { + return fmt.Errorf("source-workspace compatibility roster must have exactly one fallback plugin") + } + if !r.Plugins[len(r.Plugins)-1].Fallback { + return fmt.Errorf("source-workspace compatibility fallback plugin must be last") + } + return nil +} + +func (r CompatibilityRoster) SelectPlugin(sourceDir string) (*resources.Agent, SelectionEvidence, error) { + for _, plugin := range r.Plugins { + for _, marker := range plugin.Markers { + if _, err := os.Stat(filepath.Join(sourceDir, marker)); err == nil { + return plugin.Agent(), SelectionEvidence{Kind: "marker", Value: marker}, nil + } else if !os.IsNotExist(err) { + return nil, SelectionEvidence{}, fmt.Errorf("inspect %s source marker: %w", marker, err) + } + } + } + + pluginsByExtension := map[string]PluginCompatibility{} + for _, plugin := range r.Plugins { + for _, extension := range plugin.Extensions { + pluginsByExtension[extension] = plugin + } + } + var selected *resources.Agent + var evidence SelectionEvidence + err := filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if path != sourceDir && skipDetectionDir(entry.Name()) { + return filepath.SkipDir + } + return nil + } + extension := strings.ToLower(filepath.Ext(entry.Name())) + if plugin, ok := pluginsByExtension[extension]; ok { + selected = plugin.Agent() + evidence = SelectionEvidence{Kind: "extension", Value: extension} + return filepath.SkipAll + } + return nil + }) + if err != nil { + return nil, SelectionEvidence{}, fmt.Errorf("inspect source files: %w", err) + } + if selected != nil { + return selected, evidence, nil + } + for _, plugin := range r.Plugins { + if plugin.Fallback { + return plugin.Agent(), SelectionEvidence{Kind: "fallback"}, nil + } + } + panic("validated source-workspace compatibility roster has no fallback") +} diff --git a/pkg/sourceworkspace/roster_test.go b/pkg/sourceworkspace/roster_test.go new file mode 100644 index 00000000..3e7935b5 --- /dev/null +++ b/pkg/sourceworkspace/roster_test.go @@ -0,0 +1,116 @@ +package sourceworkspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEmbeddedCompatibilityRosterDrivesExportedPins(t *testing.T) { + tests := []struct { + name string + version string + }{ + {name: "go", version: GenericGoPluginVersion}, + {name: "python", version: GenericPythonPluginVersion}, + {name: "generic", version: GenericPluginVersion}, + {name: "nextjs", version: NodePluginVersion}, + {name: "rust", version: RustPluginVersion}, + {name: "swift", version: SwiftPluginVersion}, + } + for _, test := range tests { + plugin, ok := PinnedPlugin("codefly.dev", test.name) + if !ok { + t.Fatalf("missing codefly.dev/%s", test.name) + } + if plugin.Version != test.version { + t.Fatalf("codefly.dev/%s version = %q, exported pin = %q", test.name, plugin.Version, test.version) + } + } +} + +func TestCompatibilityRosterSelectionReportsEvidence(t *testing.T) { + tests := []struct { + file string + name string + evidence string + }{ + {file: "go.mod", name: "go", evidence: "marker:go.mod"}, + {file: "requirements.txt", name: "python", evidence: "marker:requirements.txt"}, + {file: "main.ts", name: "nextjs", evidence: "extension:.ts"}, + {file: "main.rs", name: "rust", evidence: "extension:.rs"}, + } + for _, test := range tests { + t.Run(test.file, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, test.file), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + plugin, evidence, err := Roster().SelectPlugin(dir) + if err != nil { + t.Fatal(err) + } + if plugin.Name != test.name || evidence.String() != test.evidence { + t.Fatalf("selection = %s via %s, want %s via %s", plugin.Name, evidence, test.name, test.evidence) + } + }) + } +} + +func TestParseCompatibilityRosterRejectsFloatingAndAmbiguousEntries(t *testing.T) { + tests := []struct { + name string + payload string + }{ + { + name: "floating version", + payload: `{"schema_version":1,"plugins":[ + {"publisher":"codefly.dev","name":"go","version":"latest","markers":["go.mod"]}, + {"publisher":"codefly.dev","name":"generic","version":"0.0.1","fallback":true} + ]}`, + }, + { + name: "duplicate marker", + payload: `{"schema_version":1,"plugins":[ + {"publisher":"codefly.dev","name":"go","version":"0.0.1","markers":["project"]}, + {"publisher":"codefly.dev","name":"python","version":"0.0.1","markers":["project"]}, + {"publisher":"codefly.dev","name":"generic","version":"0.0.1","fallback":true} + ]}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := ParseCompatibilityRoster([]byte(test.payload)); err == nil { + t.Fatal("invalid roster was accepted") + } + }) + } +} + +func TestWriteCompatibilityRosterPromotesSelectionPin(t *testing.T) { + roster := Roster() + for i := range roster.Plugins { + if roster.Plugins[i].Name == "go" { + roster.Plugins[i].Version = "9.9.9" + } + } + path := filepath.Join(t.TempDir(), "compatibility.json") + if err := WriteCompatibilityRoster(path, roster); err != nil { + t.Fatal(err) + } + written, err := LoadCompatibilityRoster(path) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + plugin, _, err := written.SelectPlugin(dir) + if err != nil { + t.Fatal(err) + } + if plugin.Version != "9.9.9" { + t.Fatalf("selected version = %q, want promoted 9.9.9", plugin.Version) + } +} diff --git a/pkg/sourceworkspace/source.go b/pkg/sourceworkspace/source.go index 6f360243..575d23a4 100644 --- a/pkg/sourceworkspace/source.go +++ b/pkg/sourceworkspace/source.go @@ -16,19 +16,6 @@ import ( "golang.org/x/mod/modfile" ) -const ( - GenericGoPluginVersion = "0.0.33" - GenericPythonPluginVersion = "0.0.52" - GenericPluginVersion = "0.0.26" - // NodePluginVersion is published under the historical nextjs agent name, - // but owns generic Node.js/TypeScript validation as well as Next.js-specific - // lifecycle behavior selected from the package manifest. - NodePluginVersion = "0.0.141" - RustPluginVersion = "0.0.29" - SwiftPluginVersion = "0.0.16" - pythonSetupMarker = "setup.py" -) - // Prepared is a loaded ephemeral workspace containing one source resource. type Prepared struct { Workspace *resources.Workspace @@ -54,83 +41,8 @@ func (p *Prepared) Close() error { // SelectPlugin returns the authoritative plugin for a checkout. This registry // is intentionally typed and extensible; callers never select native commands. func SelectPlugin(sourceDir string) (*resources.Agent, error) { - candidates := []struct { - marker string - name string - version string - }{ - {marker: "go.mod", name: "go", version: GenericGoPluginVersion}, - {marker: "pyproject.toml", name: "python", version: GenericPythonPluginVersion}, - {marker: "uv.lock", name: "python", version: GenericPythonPluginVersion}, - {marker: pythonSetupMarker, name: "python", version: GenericPythonPluginVersion}, - {marker: "setup.cfg", name: "python", version: GenericPythonPluginVersion}, - {marker: "requirements.in", name: "python", version: GenericPythonPluginVersion}, - {marker: "requirements.txt", name: "python", version: GenericPythonPluginVersion}, - {marker: "package.json", name: "nextjs", version: NodePluginVersion}, - {marker: "Cargo.toml", name: "rust", version: RustPluginVersion}, - {marker: "Package.swift", name: "swift", version: SwiftPluginVersion}, - } - for _, candidate := range candidates { - if _, err := os.Stat(filepath.Join(sourceDir, candidate.marker)); err == nil { - return &resources.Agent{ - Kind: resources.ServiceAgent, Publisher: "codefly.dev", - Name: candidate.name, Version: candidate.version, - }, nil - } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("inspect %s source marker: %w", candidate.marker, err) - } - } - // Markerless single-file repositories are common during editing and in - // generated worktrees. Extension evidence is sufficient to select the - // language plugin; the plugin remains authoritative for tool execution. - var selected *resources.Agent - err := filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - if path != sourceDir && skipDetectionDir(entry.Name()) { - return filepath.SkipDir - } - return nil - } - var name, version string - switch strings.ToLower(filepath.Ext(entry.Name())) { - case ".go": - name, version = "go", GenericGoPluginVersion - case ".py": - name, version = "python", GenericPythonPluginVersion - case ".rs": - name, version = "rust", RustPluginVersion - case ".swift": - name, version = "swift", SwiftPluginVersion - case ".js", ".jsx", ".ts", ".tsx": - name, version = "nextjs", NodePluginVersion - } - if name != "" { - selected = &resources.Agent{ - Kind: resources.ServiceAgent, Publisher: "codefly.dev", - Name: name, Version: version, - } - return filepath.SkipAll - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("inspect source files: %w", err) - } - if selected != nil { - return selected, nil - } - // The generic agent is the language-neutral fallback. It owns baseline - // Code and Tooling capabilities and reports language runtime operations as - // typed unsupported results; it never guesses a native command. This keeps - // every valid source tree routable without growing a language registry in - // adapters such as Mind. - return &resources.Agent{ - Kind: resources.ServiceAgent, Publisher: "codefly.dev", - Name: "generic", Version: GenericPluginVersion, - }, nil + plugin, _, err := compatibilityRoster.SelectPlugin(sourceDir) + return plugin, err } func skipDetectionDir(name string) bool { diff --git a/pkg/sourceworkspace/source_test.go b/pkg/sourceworkspace/source_test.go index 08fc6406..f0c8dd78 100644 --- a/pkg/sourceworkspace/source_test.go +++ b/pkg/sourceworkspace/source_test.go @@ -51,7 +51,7 @@ func TestSelectPluginCoversFixerLanguages(t *testing.T) { {marker: "go.mod", name: "go", version: GenericGoPluginVersion}, {marker: "pyproject.toml", name: "python", version: GenericPythonPluginVersion}, {marker: "uv.lock", name: "python", version: GenericPythonPluginVersion}, - {marker: pythonSetupMarker, name: "python", version: GenericPythonPluginVersion}, + {marker: "setup.py", name: "python", version: GenericPythonPluginVersion}, {marker: "setup.cfg", name: "python", version: GenericPythonPluginVersion}, {marker: "requirements.in", name: "python", version: GenericPythonPluginVersion}, {marker: "requirements.txt", name: "python", version: GenericPythonPluginVersion}, @@ -78,7 +78,7 @@ func TestSelectPluginCoversFixerLanguages(t *testing.T) { func TestSelectPluginPrefersPythonPackageOverFrontendManifest(t *testing.T) { dir := t.TempDir() - for _, marker := range []string{pythonSetupMarker, "package.json"} { + for _, marker := range []string{"setup.py", "package.json"} { if err := os.WriteFile(filepath.Join(dir, marker), []byte("marker"), 0o644); err != nil { t.Fatal(err) }