diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 5e78568b..cce01d97 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -93,6 +93,20 @@ jobs: with: config: ./.testcoverage.yaml + # The CGO-free companion/self-cross build (`-tags codefly_nosemantic`, + # CGO_ENABLED=0) selects pkg/engine/source_nosemantic.go. `codefly + # companion publish` exercises it only in companions.yaml, which needs + # Docker; verify here, unconditionally, that the variant both links + # statically and runs its base source behavior. Without this a regression + # in the analyzer-free variant would surface only at publish time. + - name: Verify CGO-free (companion) build variant + if: matrix.gate == 'coverage' + env: + CGO_ENABLED: "0" + run: | + go build -tags codefly_nosemantic -ldflags '-s -w -extldflags "-static"' -o /dev/null . + go test -tags codefly_nosemantic ./pkg/engine/ -run '^TestNewSourceExecutesLanguageNeutralOperation$' -count=1 + - name: Test with race detection if: matrix.gate == 'race' run: go test -failfast -race ./... -v diff --git a/cmd/companion/build.go b/cmd/companion/build.go index a054dae8..1a0776e6 100644 --- a/cmd/companion/build.go +++ b/cmd/companion/build.go @@ -279,6 +279,13 @@ func needsLinuxCLI(targets []*Companion) bool { // the alpine images can run without glibc. Flag stripping (-s -w) // drops the symbol table and DWARF info; ~25-30% size reduction with // no runtime cost. +// +// The `codefly_nosemantic` tag drops the tree-sitter semantic analyzer, which +// cannot link without cgo. It is required (not merely implied by CGO_ENABLED=0): +// without it this CGO-free build would select the analyzer variant and fail to +// link. The companion CLI only builds and runs services in-container and never +// serves the semantic gateway, so the analyzer is not needed here. See +// pkg/engine/source_nosemantic.go. func buildLinuxCLI(coreDir, arch string) error { cliDir := filepath.Join(coreDir, "..", "cli") if info, err := os.Stat(cliDir); err != nil || !info.IsDir() { @@ -290,6 +297,7 @@ func buildLinuxCLI(coreDir, arch string) error { } cmd := exec.Command("go", "build", + "-tags", "codefly_nosemantic", "-ldflags", `-s -w -extldflags "-static"`, "-o", outBin, ".", diff --git a/cmd/self/build.go b/cmd/self/build.go index 3599914d..2864f19a 100644 --- a/cmd/self/build.go +++ b/cmd/self/build.go @@ -245,12 +245,20 @@ func defaultCrossOutput(cliSrcDir, goos string) string { // buildCLICross compiles a static CLI binary for goos/goarch. Static // (CGO_ENABLED=0 + -extldflags "-static") so alpine images can run it // without glibc; stripped (-s -w) for size. Replaces scripts/build/linux.sh. +// +// The `codefly_nosemantic` tag drops the tree-sitter semantic analyzer, which +// cannot link without cgo. It is required (not merely implied by CGO_ENABLED=0): +// without it this CGO-free build would select the analyzer variant and fail to +// link. This cross binary is an in-container artifact that never serves the +// semantic gateway, and it is never installed over the running CLI, so the +// analyzer is not needed here. See pkg/engine/source_nosemantic.go. func buildCLICross(ctx context.Context, srcDir, output, goos, goarch string) error { if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { return fmt.Errorf("create output dir: %w", err) } start := time.Now() build := exec.CommandContext(ctx, "go", "build", + "-tags", "codefly_nosemantic", "-ldflags", `-s -w -extldflags "-static"`, "-o", output, ".", diff --git a/pkg/engine/source.go b/pkg/engine/source.go index f08040fd..9546390b 100644 --- a/pkg/engine/source.go +++ b/pkg/engine/source.go @@ -9,7 +9,6 @@ import ( "github.com/codefly-dev/cli/pkg/sourceworkspace" codecore "github.com/codefly-dev/core/code" - "github.com/codefly-dev/core/code/semantic" codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" ) @@ -20,14 +19,6 @@ type Source struct { server *codecore.DefaultCodeServer } -// Core omits the tree-sitter analyzer by default so Go service agents stay -// CGO-free. The CLI is the workspace-wide source behavior behind the gateway — -// semantic index and symbol mutation are part of its contract — so it installs -// the analyzer explicitly. See core/code.WithSemanticAnalyzer. -func newSource(root string) *Source { - return &Source{server: codecore.NewDefaultCodeServer(root, codecore.WithSemanticAnalyzer(semantic.New()))} -} - // ExecuteCode executes a language-neutral Code request. func (s *Source) ExecuteCode(ctx context.Context, request *codev0.CodeRequest) (*codev0.CodeResponse, error) { if s == nil { diff --git a/pkg/engine/source_nosemantic.go b/pkg/engine/source_nosemantic.go new file mode 100644 index 00000000..6ddee1c9 --- /dev/null +++ b/pkg/engine/source_nosemantic.go @@ -0,0 +1,23 @@ +//go:build codefly_nosemantic + +package engine + +import codecore "github.com/codefly-dev/core/code" + +// Without the tree-sitter semantic analyzer the source behavior runs on core's +// CGO-free default. This variant is selected only when a build explicitly sets +// the `codefly_nosemantic` tag; the tag is the intent, and cgo being disabled is +// merely how these builds also happen to be linked. Gating on an explicit tag +// (rather than on `!cgo`) means an accidental CGO_ENABLED=0 build still fails +// loudly instead of silently producing an analyzer-less CLI — see +// source_semantic.go. +// +// Two build paths opt in, both producing statically linked binaries for alpine +// (CGO_ENABLED=0, -extldflags "-static") that only build and run services +// in-container and never serve the semantic gateway, so the analyzer is not +// needed: `codefly companion build`/`publish` (cmd/companion/build.go) and +// `codefly self build --os/--arch` (cmd/self/build.go, buildCLICross). Neither +// installs over the user's running CLI, which keeps cgo and the analyzer. +func newSource(root string) *Source { + return &Source{server: codecore.NewDefaultCodeServer(root)} +} diff --git a/pkg/engine/source_semantic.go b/pkg/engine/source_semantic.go new file mode 100644 index 00000000..2eb53058 --- /dev/null +++ b/pkg/engine/source_semantic.go @@ -0,0 +1,24 @@ +//go:build !codefly_nosemantic + +package engine + +import ( + codecore "github.com/codefly-dev/core/code" + "github.com/codefly-dev/core/code/semantic" +) + +// Core omits the tree-sitter analyzer by default so Go service agents stay +// CGO-free. The CLI is the workspace-wide source behavior behind the gateway — +// semantic index and symbol mutation are part of its contract — so it installs +// the analyzer explicitly. See core/code.WithSemanticAnalyzer. +// +// This is the default variant: it is selected for every build that does NOT set +// the `codefly_nosemantic` tag. The analyzer's tree-sitter bindings require cgo, +// so a build that disables cgo without also setting the tag selects this file +// and fails to link ("build constraints exclude all Go files") — a loud signal +// that a normal CLI needs cgo, rather than a silent drop of the gateway. Only +// the in-container static builds that opt in with `codefly_nosemantic` get the +// analyzer-free variant — see source_nosemantic.go. +func newSource(root string) *Source { + return &Source{server: codecore.NewDefaultCodeServer(root, codecore.WithSemanticAnalyzer(semantic.New()))} +} diff --git a/pkg/engine/source_test.go b/pkg/engine/source_test.go new file mode 100644 index 00000000..fb2df6fa --- /dev/null +++ b/pkg/engine/source_test.go @@ -0,0 +1,51 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "testing" + + codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" +) + +// TestNewSourceExecutesLanguageNeutralOperation locks the newSource contract: +// whichever build variant is compiled (analyzer via source_semantic.go, or the +// CGO-free source_nosemantic.go selected by -tags codefly_nosemantic), the +// constructor must return a live Source whose base, language-neutral behavior +// works. Run under `-tags codefly_nosemantic` this is the only cli-side test +// that exercises the analyzer-free variant's runtime, closing the gap where the +// static companion build was verified to link but never to function. +func TestNewSourceExecutesLanguageNeutralOperation(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "marker.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("seed source tree: %v", err) + } + + source := newSource(root) + if source == nil { + t.Fatal("newSource returned nil") + } + t.Cleanup(func() { _ = source.Close() }) + + response, err := source.ExecuteCode(context.Background(), &codev0.CodeRequest{ + Operation: &codev0.CodeRequest_ListFiles{ListFiles: &codev0.ListFilesRequest{}}, + }) + if err != nil { + t.Fatalf("ListFiles execute: %v", err) + } + if failure := response.GetFailure(); failure != nil { + t.Fatalf("ListFiles reported failure: %v", failure) + } + + found := false + for _, file := range response.GetListFiles().GetFiles() { + if filepath.Base(file.GetPath()) == "marker.txt" { + found = true + break + } + } + if !found { + t.Fatalf("ListFiles did not return the seeded file; got %+v", response.GetListFiles().GetFiles()) + } +}