From 3a1d4dd79475fa7a5e8c1d21facd174606dc1013 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:04 -0400 Subject: [PATCH 1/7] chore(setup): add SDK/project detection library Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/detector.go | 351 ++++++++++++++++++++++++++ internal/setup/detector_ruby_test.go | 33 +++ internal/setup/detector_test.go | 357 +++++++++++++++++++++++++++ 3 files changed, 741 insertions(+) create mode 100644 internal/setup/detector.go create mode 100644 internal/setup/detector_ruby_test.go create mode 100644 internal/setup/detector_test.go diff --git a/internal/setup/detector.go b/internal/setup/detector.go new file mode 100644 index 00000000..cf201c8a --- /dev/null +++ b/internal/setup/detector.go @@ -0,0 +1,351 @@ +package setup + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// DetectResult contains information about the user's project detected from the working directory. +type DetectResult struct { + Language string `json:"language"` + Framework string `json:"framework,omitempty"` + PackageManager string `json:"package_manager"` + SDKID string `json:"sdk_id"` + EntryPoint string `json:"entry_point"` +} + +// Detector inspects a directory to determine the language, framework, package manager, +// recommended SDK, and entry point file. +type Detector interface { + Detect(dir string) (*DetectResult, error) +} + +// StubDetector is a placeholder implementation. Replace with real detection logic. +type StubDetector struct{} + +var _ Detector = StubDetector{} + +func (StubDetector) Detect(_ string) (*DetectResult, error) { + return nil, errors.New("detect is not yet implemented: a real Detector must be provided") +} + +// FileDetector implements Detector by scanning the filesystem for known project indicators. +type FileDetector struct{} + +var _ Detector = FileDetector{} + +// Detect scans dir for known project files and returns a DetectResult with language, +// framework, SDK ID, package manager, and a suggested entry point file. +// Returns an error if the project type cannot be determined. +func (FileDetector) Detect(dir string) (*DetectResult, error) { + if result := detectNode(dir); result != nil { + return result, nil + } + if result := detectGo(dir); result != nil { + return result, nil + } + if result := detectPython(dir); result != nil { + return result, nil + } + if result := detectRuby(dir); result != nil { + return result, nil + } + if result := detectJava(dir); result != nil { + return result, nil + } + if result := detectSwift(dir); result != nil { + return result, nil + } + if result := detectDotnet(dir); result != nil { + return result, nil + } + return nil, errors.New("could not detect project language from directory; try specifying --sdk-id manually") +} + +func detectNode(dir string) *DetectResult { + pkgBytes, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return nil + } + + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if json.Unmarshal(pkgBytes, &pkg) != nil { + return nil + } + + allDeps := make(map[string]string, len(pkg.Dependencies)+len(pkg.DevDependencies)) + for k, v := range pkg.Dependencies { + allDeps[k] = v + } + for k, v := range pkg.DevDependencies { + allDeps[k] = v + } + + pm := detectNodePM(dir) + + // Next.js apps run a Node server (SSR and API routes), so server-side flag + // evaluation uses the Node server SDK rather than a browser client SDK. + if _, ok := allDeps["next"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "Next.js", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "pages/index.tsx", "pages/index.ts", "pages/index.js", + "index.js", + })), + } + } + + if _, ok := allDeps["react-native"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + if _, ok := allDeps["react"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React", + PackageManager: pm, + SDKID: "react-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + jsClientFrameworks := []struct{ dep, framework string }{ + {"backbone", "Backbone"}, + {"svelte", "Svelte"}, + {"vue", "Vue"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, fw := range jsClientFrameworks { + if _, ok := allDeps[fw.dep]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: fw.framework, + PackageManager: pm, + SDKID: "js-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "src/main.ts", "src/main.js", "index.js", + })), + } + } + } + + return &DetectResult{ + Language: "JavaScript", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + })), + } +} + +func detectNodePM(dir string) string { + if _, err := os.Stat(filepath.Join(dir, "pnpm-lock.yaml")); err == nil { + return "pnpm" + } + if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { + return "yarn" + } + if _, err := os.Stat(filepath.Join(dir, "bun.lock")); err == nil { + return "bun" + } + return "npm" +} + +func detectGo(dir string) *DetectResult { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { + return nil + } + return &DetectResult{ + Language: "Go", + PackageManager: "go", + SDKID: "go-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})), + } +} + +func detectPython(dir string) *DetectResult { + for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + return &DetectResult{ + Language: "Python", + PackageManager: "pip", + SDKID: "python-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/main.py", "manage.py", "app.py", "main.py", + })), + } + } + } + return nil +} + +func detectRuby(dir string) *DetectResult { + found := false + for _, indicator := range []string{"Gemfile", "Gemfile.lock", "config.ru"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + found = true + break + } + } + if !found { + if matches, _ := filepath.Glob(filepath.Join(dir, "*.gemspec")); len(matches) == 0 { + return nil + } + } + return &DetectResult{ + Language: "Ruby", + PackageManager: "gem", + SDKID: "ruby-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "config.ru", "app.rb", "main.rb", + })), + } +} + +func detectJava(dir string) *DetectResult { + for _, indicator := range []string{"pom.xml", "build.gradle", "build.gradle.kts"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + pm := "gradle" + if indicator == "pom.xml" { + pm = "mvn" + } + // Android projects use Gradle but are distinguished by AndroidManifest.xml. + for _, manifest := range []string{ + "app/src/main/AndroidManifest.xml", + "src/main/AndroidManifest.xml", + } { + if _, err := os.Stat(filepath.Join(dir, manifest)); err == nil { + return &DetectResult{ + Language: "Java", + PackageManager: "gradle", + SDKID: "android-client-sdk", + EntryPoint: filepath.Join(dir, "app/src/main/java/MainActivity.java"), + } + } + } + return &DetectResult{ + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: filepath.Join(dir, "src/main/java/Main.java"), + } + } + } + return nil +} + +func detectSwift(dir string) *DetectResult { + pm := "spm" + if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil { + pm = "cocoapods" + } + indicators := []string{"Package.swift", "Podfile"} + for _, f := range indicators { + if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + } + matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) + if len(matches) > 0 { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + return nil +} + +func detectDotnet(dir string) *DetectResult { + for _, pattern := range []string{"*.csproj", "*.sln"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + if len(matches) > 0 { + return &DetectResult{ + Language: "C#", + PackageManager: "dotnet", + SDKID: "dotnet-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Program.cs", "Startup.cs", "src/Program.cs", + })), + } + } + } + return nil +} + +// SDKOption describes a LaunchDarkly SDK available for use with ldcli setup. +type SDKOption struct { + ID string + Language string + Name string +} + +// KnownSDKs is the ordered list of SDKs available for manual selection when +// auto-detection fails or the user wants to override the detected SDK. +var KnownSDKs = []SDKOption{ + {ID: "node-server", Language: "JavaScript", Name: "Node.js"}, + {ID: "react-client-sdk", Language: "JavaScript", Name: "React"}, + {ID: "react-native", Language: "JavaScript", Name: "React Native"}, + {ID: "js-client-sdk", Language: "JavaScript", Name: "JavaScript (Browser)"}, + {ID: "python-server-sdk", Language: "Python", Name: "Python"}, + {ID: "go-server-sdk", Language: "Go", Name: "Go"}, + {ID: "java-server-sdk", Language: "Java", Name: "Java"}, + {ID: "android-client-sdk", Language: "Java", Name: "Android"}, + {ID: "dotnet-server-sdk", Language: "C#", Name: ".NET"}, + {ID: "swift-client-sdk", Language: "Swift", Name: "iOS/Swift"}, + {ID: "ruby-server-sdk", Language: "Ruby", Name: "Ruby"}, +} + +// firstExistingIn returns the first candidate that exists as a file in dir, +// or the last candidate if none exist (as a suggested path). +// Returns an empty string if candidates is empty. +func firstExistingIn(dir string, candidates []string) string { + if len(candidates) == 0 { + return "" + } + for _, c := range candidates { + if _, err := os.Stat(filepath.Join(dir, c)); err == nil { + return c + } + } + return candidates[len(candidates)-1] +} diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go new file mode 100644 index 00000000..9dadbbc4 --- /dev/null +++ b/internal/setup/detector_ruby_test.go @@ -0,0 +1,33 @@ +package setup + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileDetector_DetectsRuby_Gemfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n") + writeDetectFile(t, dir, "app.rb", "# app\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", result.SDKID) + assert.Equal(t, "Ruby", result.Language) + assert.Equal(t, "gem", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "app.rb"), result.EntryPoint) +} + +func TestFileDetector_DetectsRuby_Gemspec(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "mygem.gemspec", "Gem::Specification.new\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", result.SDKID) +} diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go new file mode 100644 index 00000000..ce8945ea --- /dev/null +++ b/internal/setup/detector_test.go @@ -0,0 +1,357 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeDetectFile writes content to a file in dir, creating parent directories as needed. +func writeDetectFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) +} + +func TestFileDetector_DetectsReact(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + writeDetectFile(t, dir, "src/App.tsx", "// App") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-client-sdk", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React", result.Framework) + assert.Equal(t, "npm", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint) +} + +func TestFileDetector_DetectsReactNative(t *testing.T) { + dir := t.TempDir() + // React Native projects always list both "react" and "react-native" as deps; + // react-native must be checked first so it takes priority over react. + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","react-native":"^0.73.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-native", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React Native", result.Framework) +} + +func TestFileDetector_DetectsNextJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^14.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "Next.js", result.Framework) +} + +func TestFileDetector_DetectsNodeJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"express":"^4.0.0"}}`) + writeDetectFile(t, dir, "index.js", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Empty(t, result.Framework) + assert.Equal(t, filepath.Join(dir, "index.js"), result.EntryPoint) +} + +func TestFileDetector_DetectsGo(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module example.com/myapp\n\ngo 1.21\n") + writeDetectFile(t, dir, "main.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "go-server-sdk", result.SDKID) + assert.Equal(t, "Go", result.Language) + assert.Equal(t, "go", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint) +} + +func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "requirements.txt", "flask==3.0.0\n") + writeDetectFile(t, dir, "app.py", "# app") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) + assert.Equal(t, "Python", result.Language) + assert.Equal(t, "pip", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "app.py"), result.EntryPoint) +} + +func TestFileDetector_DetectsPython_Pyproject(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pyproject.toml", "[tool.poetry]\nname = \"myapp\"\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) +} + +func TestFileDetector_DetectsJava_PomXml(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pom.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "mvn", result.PackageManager) +} + +func TestFileDetector_DetectsJava_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_KotlinDsl(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsJava_NotAndroid(t *testing.T) { + // build.gradle without AndroidManifest.xml should still return java-server-sdk + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) +} + +func TestFileDetector_UnknownProject_ReturnsError(t *testing.T) { + dir := t.TempDir() + + _, err := FileDetector{}.Detect(dir) + + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") +} + +func TestFileDetector_DetectsNodePM_Pnpm(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "pnpm-lock.yaml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "pnpm", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Yarn(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "yarn.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "yarn", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Bun(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "bun.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bun", result.PackageManager) +} + +func TestFileDetector_DetectsJsClientFramework(t *testing.T) { + tests := []struct { + dep string + framework string + }{ + {"vue", "Vue"}, + {"svelte", "Svelte"}, + {"backbone", "Backbone"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, tt := range tests { + t.Run(tt.framework, func(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"`+tt.dep+`":"^1.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "js-client-sdk", result.SDKID) + assert.Equal(t, tt.framework, result.Framework) + }) + } +} + +func TestFileDetector_DetectsSwift_PackageSwift(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) + assert.Equal(t, "spm", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_Podfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Podfile", "platform :ios, '14.0'") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "cocoapods", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_XcodeProj(t *testing.T) { + dir := t.TempDir() + // .xcodeproj is a directory in practice, but we use Glob so creating the dir is enough + require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755)) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) +} + +func TestFileDetector_DetectsDotnet_Csproj(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.csproj", "") + writeDetectFile(t, dir, "Program.cs", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "C#", result.Language) + assert.Equal(t, "dotnet", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "Program.cs"), result.EntryPoint) +} + +func TestFileDetector_DetectsDotnet_Sln(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.sln", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "dotnet", result.PackageManager) +} + +func TestKnownSDKs_ContainsExpectedSDKs(t *testing.T) { + ids := make([]string, len(KnownSDKs)) + for i, sdk := range KnownSDKs { + ids[i] = sdk.ID + } + assert.Contains(t, ids, "node-server") + assert.Contains(t, ids, "react-client-sdk") + assert.Contains(t, ids, "react-native") + assert.Contains(t, ids, "python-server-sdk") + assert.Contains(t, ids, "go-server-sdk") + assert.Contains(t, ids, "java-server-sdk") + assert.Contains(t, ids, "dotnet-server-sdk") + assert.Contains(t, ids, "swift-client-sdk") + assert.Contains(t, ids, "ruby-server-sdk") +} + +func TestFileDetector_EntryPointFallback_WhenNoneExist(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + // No src/App.tsx or other entry point files + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + // Falls back to last candidate + assert.NotEmpty(t, result.EntryPoint) +} + +func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `not valid json {{{`) + // No other project indicators + + _, err := FileDetector{}.Detect(dir) + + // detectNode skips invalid JSON; no other indicators → error + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") +} + +func TestFirstExistingIn_EmptySlice_ReturnsEmpty(t *testing.T) { + result := firstExistingIn(t.TempDir(), []string{}) + assert.Empty(t, result) +} + +func TestFirstExistingIn_NoMatch_ReturnLastCandidate(t *testing.T) { + dir := t.TempDir() + result := firstExistingIn(dir, []string{"nonexistent.go", "also-nonexistent.go"}) + assert.Equal(t, "also-nonexistent.go", result) +} + +func TestFirstExistingIn_MatchesFirst(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "second.go", "") + writeDetectFile(t, dir, "first.go", "") + result := firstExistingIn(dir, []string{"first.go", "second.go"}) + assert.Equal(t, "first.go", result) +} From 9efecc77c2fe1774c59264be37e2f999b7096441 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 23:47:34 -0400 Subject: [PATCH 2/7] fix(setup): report whether the detected entry point exists DetectResult carries EntryPointExists so callers can tell an entry file the detector found from one it merely suggests, and never write initialization code into a path the project does not load. PackageManager names the tool that manages the project's dependencies: bundle rather than gem when a Gemfile is present, and poetry, uv or pipenv rather than always pip. Locate MainActivity and Main under their real package directory rather than assuming an unqualified class name, derive the Android source root from whichever manifest matched, find the Swift entry point where SwiftPM and Xcode nest it, look for src/main.tsx where Vite mounts a React app, and recognise bun.lockb. Rename the Android SDK ID to android for consistency with the other IDs. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 379 ++++++++++++++++++-------- internal/setup/detector_ruby_test.go | 2 +- internal/setup/detector_test.go | 390 ++++++++++++++++++++++++++- 3 files changed, 652 insertions(+), 119 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index cf201c8a..cb5d85fe 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -1,10 +1,13 @@ package setup import ( + "bytes" "encoding/json" "errors" + "io/fs" "os" "path/filepath" + "strings" ) // DetectResult contains information about the user's project detected from the working directory. @@ -14,6 +17,10 @@ type DetectResult struct { PackageManager string `json:"package_manager"` SDKID string `json:"sdk_id"` EntryPoint string `json:"entry_point"` + // EntryPointExists distinguishes an entry point we found from one we merely + // suggest. Callers must not write initialization code into a suggested path + // without telling the user, since the project does not load that file. + EntryPointExists bool `json:"entry_point_exists"` } // Detector inspects a directory to determine the language, framework, package manager, @@ -91,43 +98,59 @@ func detectNode(dir string) *DetectResult { // Next.js apps run a Node server (SSR and API routes), so server-side flag // evaluation uses the Node server SDK rather than a browser client SDK. if _, ok := allDeps["next"]; ok { + // instrumentation.ts is Next's server-startup hook, which runs once before + // any request and is the only entry file that suits a server SDK in both + // the App Router and the Pages Router. It is also what we create when the + // project has no suitable file yet. + ep, exists := entryPoint(dir, "instrumentation.ts", + "instrumentation.ts", "instrumentation.js", + "src/instrumentation.ts", "src/instrumentation.js", + "app/page.tsx", "src/app/page.tsx", + "pages/index.tsx", "pages/index.ts", "pages/index.js", + "src/index.ts", "src/index.js", + ) return &DetectResult{ - Language: "JavaScript", - Framework: "Next.js", - PackageManager: pm, - SDKID: "node-server", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/index.ts", "src/index.js", - "pages/index.tsx", "pages/index.ts", "pages/index.js", - "index.js", - })), + Language: "JavaScript", + Framework: "Next.js", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: ep, + EntryPointExists: exists, } } if _, ok := allDeps["react-native"]; ok { + ep, exists := entryPoint(dir, "index.js", + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "App.tsx", "App.js", "index.js", + ) return &DetectResult{ - Language: "JavaScript", - Framework: "React Native", - PackageManager: pm, - SDKID: "react-native", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/App.tsx", "src/App.jsx", "src/App.js", - "src/index.tsx", "src/index.jsx", "src/index.js", - "index.js", - })), + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native", + EntryPoint: ep, + EntryPointExists: exists, } } if _, ok := allDeps["react"]; ok { + // src/main.tsx is where Vite mounts the app and src/index.tsx is where + // Create React App does; either is a better home for the provider than a + // component file, but App.tsx works and is the more familiar edit. + ep, exists := entryPoint(dir, "src/App.tsx", + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/main.tsx", "src/main.jsx", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + ) return &DetectResult{ - Language: "JavaScript", - Framework: "React", - PackageManager: pm, - SDKID: "react-client-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/App.tsx", "src/App.jsx", "src/App.js", - "src/index.tsx", "src/index.jsx", "src/index.js", - "index.js", - })), + Language: "JavaScript", + Framework: "React", + PackageManager: pm, + SDKID: "react-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } jsClientFrameworks := []struct{ dep, framework string }{ @@ -140,30 +163,34 @@ func detectNode(dir string) *DetectResult { } for _, fw := range jsClientFrameworks { if _, ok := allDeps[fw.dep]; ok { + ep, exists := entryPoint(dir, "src/main.ts", + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "src/main.ts", "src/main.js", "index.js", + ) return &DetectResult{ - Language: "JavaScript", - Framework: fw.framework, - PackageManager: pm, - SDKID: "js-client-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/App.tsx", "src/App.jsx", "src/App.js", - "src/index.tsx", "src/index.jsx", "src/index.js", - "src/main.ts", "src/main.js", "index.js", - })), + Language: "JavaScript", + Framework: fw.framework, + PackageManager: pm, + SDKID: "js-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } + ep, exists := entryPoint(dir, "index.js", + "src/index.ts", "src/index.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + ) return &DetectResult{ - Language: "JavaScript", - PackageManager: pm, - SDKID: "node-server", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/index.ts", "src/index.js", - "index.ts", "index.js", - "server.ts", "server.js", - "app.ts", "app.js", - })), + Language: "JavaScript", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: ep, + EntryPointExists: exists, } } @@ -174,8 +201,11 @@ func detectNodePM(dir string) string { if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { return "yarn" } - if _, err := os.Stat(filepath.Join(dir, "bun.lock")); err == nil { - return "bun" + // bun.lock is the text lockfile from Bun 1.2 onwards; bun.lockb is the older binary one. + for _, lock := range []string{"bun.lock", "bun.lockb"} { + if _, err := os.Stat(filepath.Join(dir, lock)); err == nil { + return "bun" + } } return "npm" } @@ -184,30 +214,55 @@ func detectGo(dir string) *DetectResult { if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { return nil } + ep, exists := entryPoint(dir, "main.go", "main.go", "cmd/main.go") return &DetectResult{ - Language: "Go", - PackageManager: "go", - SDKID: "go-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})), + Language: "Go", + PackageManager: "go", + SDKID: "go-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } func detectPython(dir string) *DetectResult { - for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py"} { + for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"} { if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + ep, exists := entryPoint(dir, "main.py", + "src/main.py", "manage.py", "app.py", "main.py", + ) return &DetectResult{ - Language: "Python", - PackageManager: "pip", - SDKID: "python-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/main.py", "manage.py", "app.py", "main.py", - })), + Language: "Python", + PackageManager: detectPythonPM(dir), + SDKID: "python-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } return nil } +// detectPythonPM identifies the tool that manages the project's dependencies, so +// callers install into the project rather than running pip against whatever +// interpreter happens to be on PATH. +func detectPythonPM(dir string) string { + if _, err := os.Stat(filepath.Join(dir, "uv.lock")); err == nil { + return "uv" + } + if _, err := os.Stat(filepath.Join(dir, "Pipfile")); err == nil { + return "pipenv" + } + if b, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")); err == nil { + if bytes.Contains(b, []byte("[tool.poetry]")) { + return "poetry" + } + if bytes.Contains(b, []byte("[tool.uv]")) { + return "uv" + } + } + return "pip" +} + func detectRuby(dir string) *DetectResult { found := false for _, indicator := range []string{"Gemfile", "Gemfile.lock", "config.ru"} { @@ -221,13 +276,19 @@ func detectRuby(dir string) *DetectResult { return nil } } + // A Gemfile means Bundler manages the project's gems, so the SDK has to be + // added to the Gemfile rather than installed into the global gem set. + pm := "gem" + if _, err := os.Stat(filepath.Join(dir, "Gemfile")); err == nil { + pm = "bundle" + } + ep, exists := entryPoint(dir, "main.rb", "config.ru", "app.rb", "main.rb") return &DetectResult{ - Language: "Ruby", - PackageManager: "gem", - SDKID: "ruby-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "config.ru", "app.rb", "main.rb", - })), + Language: "Ruby", + PackageManager: pm, + SDKID: "ruby-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } @@ -243,20 +304,34 @@ func detectJava(dir string) *DetectResult { "app/src/main/AndroidManifest.xml", "src/main/AndroidManifest.xml", } { - if _, err := os.Stat(filepath.Join(dir, manifest)); err == nil { - return &DetectResult{ - Language: "Java", - PackageManager: "gradle", - SDKID: "android-client-sdk", - EntryPoint: filepath.Join(dir, "app/src/main/java/MainActivity.java"), - } + if _, err := os.Stat(filepath.Join(dir, manifest)); err != nil { + continue + } + // The manifest tells us which source root this project uses; the + // activity itself lives under a package directory, so search for it + // rather than guessing the package name. + srcRoot := strings.TrimSuffix(manifest, "/AndroidManifest.xml") + ep, exists := entryPoint(dir, srcRoot+"/java/MainActivity.kt", + findFileUnder(dir, srcRoot+"/java", "MainActivity.kt", "MainActivity.java"), + findFileUnder(dir, srcRoot+"/kotlin", "MainActivity.kt"), + ) + return &DetectResult{ + Language: "Java", + PackageManager: "gradle", + SDKID: "android", + EntryPoint: ep, + EntryPointExists: exists, } } + ep, exists := entryPoint(dir, "src/main/java/Main.java", + findFileUnder(dir, "src/main/java", "Main.java", "Application.java", "App.java"), + ) return &DetectResult{ - Language: "Java", - PackageManager: pm, - SDKID: "java-server-sdk", - EntryPoint: filepath.Join(dir, "src/main/java/Main.java"), + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } @@ -268,44 +343,99 @@ func detectSwift(dir string) *DetectResult { if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil { pm = "cocoapods" } + swiftEntryPoint := func(appRoot string) (string, bool) { + return entryPoint(dir, "App.swift", swiftEntryCandidates(dir, appRoot)...) + } indicators := []string{"Package.swift", "Podfile"} for _, f := range indicators { if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + ep, exists := swiftEntryPoint(xcodeAppRoot(dir)) return &DetectResult{ - Language: "Swift", - PackageManager: pm, - SDKID: "swift-client-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", - })), + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } - matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) - if len(matches) > 0 { + if appRoot := xcodeAppRoot(dir); appRoot != "" { + ep, exists := swiftEntryPoint(appRoot) return &DetectResult{ - Language: "Swift", - PackageManager: pm, - SDKID: "swift-client-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", - })), + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } return nil } +// swiftEntryCandidates lists entry-point paths to try for a Swift project, most +// specific first. appRoot is the Xcode app directory, empty when there is no Xcode +// project. Any-name matches are confined to a package with a single target, where +// the entry file is named after that target; with several targets there is no way to +// tell an entry point from a helper. +func swiftEntryCandidates(dir, appRoot string) []string { + candidates := []string{ + "App.swift", "ContentView.swift", "AppDelegate.swift", + findFileUnder(dir, appRoot, "*App.swift", "ContentView.swift", "AppDelegate.swift"), + findFileUnder(dir, "Sources", "main.swift", "*App.swift"), + } + if target := soleSubdir(dir, "Sources"); target != "" { + candidates = append(candidates, + findFileUnder(dir, target, filepath.Base(target)+".swift"), + findFileUnder(dir, target, "*.swift"), + ) + } + return candidates +} + +// soleSubdir returns the path relative to dir of root's only subdirectory, or an +// empty string when root is missing or holds anything other than exactly one. +func soleSubdir(dir, root string) string { + entries, err := os.ReadDir(filepath.Join(dir, root)) + if err != nil { + return "" + } + var found string + for _, e := range entries { + if !e.IsDir() { + continue + } + if found != "" { + return "" + } + found = filepath.Join(root, e.Name()) + } + return found +} + +// xcodeAppRoot returns the source directory an Xcode project keeps its app code in, +// which the templates name after the project (MyApp.xcodeproj alongside MyApp/). +// Returns an empty string when dir holds no Xcode project. +func xcodeAppRoot(dir string) string { + matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) + if len(matches) == 0 { + return "" + } + return strings.TrimSuffix(filepath.Base(matches[0]), ".xcodeproj") +} + func detectDotnet(dir string) *DetectResult { for _, pattern := range []string{"*.csproj", "*.sln"} { matches, _ := filepath.Glob(filepath.Join(dir, pattern)) if len(matches) > 0 { + ep, exists := entryPoint(dir, "Program.cs", + "Program.cs", "Startup.cs", "src/Program.cs", + ) return &DetectResult{ - Language: "C#", - PackageManager: "dotnet", - SDKID: "dotnet-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "Program.cs", "Startup.cs", "src/Program.cs", - })), + Language: "C#", + PackageManager: "dotnet", + SDKID: "dotnet-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } @@ -329,23 +459,62 @@ var KnownSDKs = []SDKOption{ {ID: "python-server-sdk", Language: "Python", Name: "Python"}, {ID: "go-server-sdk", Language: "Go", Name: "Go"}, {ID: "java-server-sdk", Language: "Java", Name: "Java"}, - {ID: "android-client-sdk", Language: "Java", Name: "Android"}, + {ID: "android", Language: "Java", Name: "Android"}, {ID: "dotnet-server-sdk", Language: "C#", Name: ".NET"}, {ID: "swift-client-sdk", Language: "Swift", Name: "iOS/Swift"}, {ID: "ruby-server-sdk", Language: "Ruby", Name: "Ruby"}, } -// firstExistingIn returns the first candidate that exists as a file in dir, -// or the last candidate if none exist (as a suggested path). -// Returns an empty string if candidates is empty. -func firstExistingIn(dir string, candidates []string) string { - if len(candidates) == 0 { +// entryPoint returns the first candidate that exists as a file under dir, joined +// to dir, together with true. When no candidate exists it returns fallback joined +// to dir and false, so callers can tell a file we found from one we suggest. +// Empty candidates are skipped, which lets callers pass the result of a lookup +// that may have come up empty. +func entryPoint(dir, fallback string, candidates ...string) (string, bool) { + for _, c := range candidates { + if c == "" { + continue + } + if info, err := os.Stat(filepath.Join(dir, c)); err == nil && !info.IsDir() { + return filepath.Join(dir, c), true + } + } + return filepath.Join(dir, fallback), false +} + +// findFileUnder walks root (relative to dir) and returns the first file whose base +// name matches one of names, as a path relative to dir. A name may start with "*" +// to match by suffix, so "*App.swift" finds MyAppApp.swift. Names are tried in +// order so callers can express a preference. Returns an empty string when root is +// missing or contains no match. An empty root yields no match rather than walking +// the whole project. +func findFileUnder(dir, root string, names ...string) string { + if root == "" { return "" } - for _, c := range candidates { - if _, err := os.Stat(filepath.Join(dir, c)); err == nil { - return c + matches := func(base, name string) bool { + if suffix, ok := strings.CutPrefix(name, "*"); ok { + return strings.HasSuffix(base, suffix) + } + return base == name + } + for _, name := range names { + var found string + _ = filepath.WalkDir(filepath.Join(dir, root), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if !d.IsDir() && matches(d.Name(), name) { + found = path + return fs.SkipAll + } + return nil + }) + if found != "" { + if rel, err := filepath.Rel(dir, found); err == nil { + return rel + } } } - return candidates[len(candidates)-1] + return "" } diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go index 9dadbbc4..a38349c6 100644 --- a/internal/setup/detector_ruby_test.go +++ b/internal/setup/detector_ruby_test.go @@ -18,7 +18,7 @@ func TestFileDetector_DetectsRuby_Gemfile(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ruby-server-sdk", result.SDKID) assert.Equal(t, "Ruby", result.Language) - assert.Equal(t, "gem", result.PackageManager) + assert.Equal(t, "bundle", result.PackageManager) assert.Equal(t, filepath.Join(dir, "app.rb"), result.EntryPoint) } diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index ce8945ea..42e25f37 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -30,6 +30,7 @@ func TestFileDetector_DetectsReact(t *testing.T) { assert.Equal(t, "React", result.Framework) assert.Equal(t, "npm", result.PackageManager) assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint) + assert.True(t, result.EntryPointExists) } func TestFileDetector_DetectsReactNative(t *testing.T) { @@ -70,6 +71,7 @@ func TestFileDetector_DetectsNodeJs(t *testing.T) { assert.Equal(t, "JavaScript", result.Language) assert.Empty(t, result.Framework) assert.Equal(t, filepath.Join(dir, "index.js"), result.EntryPoint) + assert.True(t, result.EntryPointExists) } func TestFileDetector_DetectsGo(t *testing.T) { @@ -84,6 +86,7 @@ func TestFileDetector_DetectsGo(t *testing.T) { assert.Equal(t, "Go", result.Language) assert.Equal(t, "go", result.PackageManager) assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint) + assert.True(t, result.EntryPointExists) } func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) { @@ -98,6 +101,7 @@ func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) { assert.Equal(t, "Python", result.Language) assert.Equal(t, "pip", result.PackageManager) assert.Equal(t, filepath.Join(dir, "app.py"), result.EntryPoint) + assert.True(t, result.EntryPointExists) } func TestFileDetector_DetectsPython_Pyproject(t *testing.T) { @@ -141,7 +145,7 @@ func TestFileDetector_DetectsAndroid_BuildGradle(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "android", result.SDKID) assert.Equal(t, "Java", result.Language) assert.Equal(t, "gradle", result.PackageManager) } @@ -154,7 +158,7 @@ func TestFileDetector_DetectsAndroid_KotlinDsl(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "android", result.SDKID) assert.Equal(t, "gradle", result.PackageManager) } @@ -284,6 +288,7 @@ func TestFileDetector_DetectsDotnet_Csproj(t *testing.T) { assert.Equal(t, "C#", result.Language) assert.Equal(t, "dotnet", result.PackageManager) assert.Equal(t, filepath.Join(dir, "Program.cs"), result.EntryPoint) + assert.True(t, result.EntryPointExists) } func TestFileDetector_DetectsDotnet_Sln(t *testing.T) { @@ -321,8 +326,8 @@ func TestFileDetector_EntryPointFallback_WhenNoneExist(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - // Falls back to last candidate - assert.NotEmpty(t, result.EntryPoint) + assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint) + assert.False(t, result.EntryPointExists, "a suggested path must not look like one we found") } func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { @@ -337,21 +342,380 @@ func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { assert.Contains(t, err.Error(), "could not detect") } -func TestFirstExistingIn_EmptySlice_ReturnsEmpty(t *testing.T) { - result := firstExistingIn(t.TempDir(), []string{}) - assert.Empty(t, result) +func TestFileDetector_NextJs_AppRouter_SuggestsInstrumentation(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^15.0.0"}}`) + writeDetectFile(t, dir, "app/page.tsx", "export default function Page() {}") + writeDetectFile(t, dir, "app/layout.tsx", "export default function Layout() {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + // An App Router project has no pages/ or src/index, so the old candidate list + // fell through to a nonexistent index.js at the repo root. + assert.Equal(t, filepath.Join(dir, "app/page.tsx"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_NextJs_PrefersExistingInstrumentation(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"next":"^15.0.0"}}`) + writeDetectFile(t, dir, "instrumentation.ts", "export function register() {}") + writeDetectFile(t, dir, "app/page.tsx", "export default function Page() {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_NextJs_PagesRouter(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^13.0.0"}}`) + writeDetectFile(t, dir, "pages/index.tsx", "export default function Home() {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "pages/index.tsx"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_NextJs_Empty_SuggestsInstrumentation(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"next":"^15.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Android_FindsKotlinActivityInPackageDir(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + writeDetectFile(t, dir, "app/src/main/java/com/example/myapp/MainActivity.kt", "class MainActivity") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android", result.SDKID) + assert.Equal(t, filepath.Join(dir, "app/src/main/java/com/example/myapp/MainActivity.kt"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Android_KotlinSourceRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + writeDetectFile(t, dir, "app/src/main/kotlin/com/example/MainActivity.kt", "class MainActivity") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "app/src/main/kotlin/com/example/MainActivity.kt"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Android_NoAppModule_UsesMatchedSourceRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "src/main/AndroidManifest.xml", "") + writeDetectFile(t, dir, "src/main/java/com/example/MainActivity.java", "class MainActivity {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + // The old code hardcoded app/src/main/... even for this single-module layout. + assert.Equal(t, filepath.Join(dir, "src/main/java/com/example/MainActivity.java"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Android_NoActivity_SuggestsUnderMatchedRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "src/main/java/MainActivity.kt"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Java_FindsMainInPackageDir(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pom.xml", "") + writeDetectFile(t, dir, "src/main/java/com/example/app/Application.java", "class Application {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, filepath.Join(dir, "src/main/java/com/example/app/Application.java"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Ruby_GemfileReportsBundler(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bundle", result.PackageManager) +} + +func TestFileDetector_Ruby_NoGemfileReportsGem(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "mygem.gemspec", "Gem::Specification.new\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "gem", result.PackageManager) +} + +func TestFileDetector_PythonPackageManagers(t *testing.T) { + tests := []struct { + name string + files map[string]string + want string + }{ + {"pip", map[string]string{"requirements.txt": "flask\n"}, "pip"}, + {"poetry", map[string]string{"pyproject.toml": "[tool.poetry]\nname = \"myapp\"\n"}, "poetry"}, + {"uv lockfile", map[string]string{"pyproject.toml": "[project]\nname = \"myapp\"\n", "uv.lock": "version = 1\n"}, "uv"}, + {"uv section", map[string]string{"pyproject.toml": "[project]\nname = \"a\"\n[tool.uv]\n"}, "uv"}, + {"pipenv", map[string]string{"Pipfile": "[packages]\n"}, "pipenv"}, + {"bare pyproject", map[string]string{"pyproject.toml": "[project]\nname = \"myapp\"\n"}, "pip"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tt.files { + writeDetectFile(t, dir, name, content) + } + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) + assert.Equal(t, tt.want, result.PackageManager) + }) + } +} + +func TestFileDetector_DetectsNodePM_BunBinaryLockfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "bun.lockb", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bun", result.PackageManager) +} + +func TestKnownSDKs_UsesAndroidID(t *testing.T) { + ids := make([]string, len(KnownSDKs)) + for i, sdk := range KnownSDKs { + ids[i] = sdk.ID + } + assert.Contains(t, ids, "android") + assert.NotContains(t, ids, "android-client-sdk") } -func TestFirstExistingIn_NoMatch_ReturnLastCandidate(t *testing.T) { +func TestEntryPoint_NoCandidateExists_ReturnsFallback(t *testing.T) { dir := t.TempDir() - result := firstExistingIn(dir, []string{"nonexistent.go", "also-nonexistent.go"}) - assert.Equal(t, "also-nonexistent.go", result) + + got, exists := entryPoint(dir, "fallback.go", "nonexistent.go", "also-nonexistent.go") + + assert.Equal(t, filepath.Join(dir, "fallback.go"), got) + assert.False(t, exists) } -func TestFirstExistingIn_MatchesFirst(t *testing.T) { +func TestEntryPoint_MatchesFirstExisting(t *testing.T) { dir := t.TempDir() writeDetectFile(t, dir, "second.go", "") writeDetectFile(t, dir, "first.go", "") - result := firstExistingIn(dir, []string{"first.go", "second.go"}) - assert.Equal(t, "first.go", result) + + got, exists := entryPoint(dir, "fallback.go", "first.go", "second.go") + + assert.Equal(t, filepath.Join(dir, "first.go"), got) + assert.True(t, exists) +} + +func TestEntryPoint_SkipsEmptyAndDirectoryCandidates(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0755)) + writeDetectFile(t, dir, "real.go", "") + + got, exists := entryPoint(dir, "fallback.go", "", "src", "real.go") + + assert.Equal(t, filepath.Join(dir, "real.go"), got) + assert.True(t, exists) +} + +func TestFindFileUnder(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "src/main/java/com/example/App.java", "") + + assert.Equal(t, filepath.Join("src/main/java/com/example/App.java"), + findFileUnder(dir, "src/main/java", "Main.java", "App.java")) + assert.Empty(t, findFileUnder(dir, "src/main/java", "Missing.java")) + assert.Empty(t, findFileUnder(dir, "does/not/exist", "App.java")) +} + +// Multi-binary repos have no single entry point, so the detector must not pick one +// of them arbitrarily and report it as found. +func TestFileDetector_Go_MultipleBinaries_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module example.com/app\n\ngo 1.22\n") + writeDetectFile(t, dir, "cmd/server/main.go", "package main\n") + writeDetectFile(t, dir, "cmd/worker/main.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +// An entry file named after the module, as ld-relay and gonfalon do, is not something +// we can guess at either. +func TestFileDetector_Go_ModuleNamedEntryFile_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module github.com/launchdarkly/ld-relay/v8\n\ngo 1.22\n") + writeDetectFile(t, dir, "ld-relay.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_NestedSourcesTarget(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + // `swift package init` names the file after the target, not main.swift. + writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "print(1)") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/MyTool.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_PrefersMainSwiftInSources(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "") + writeDetectFile(t, dir, "Sources/MyTool/main.swift", "print(1)") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/main.swift"), result.EntryPoint) +} + +func TestFileDetector_Swift_XcodeAppNamedDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755)) + // Xcode's SwiftUI template puts the app code in a directory named after the project. + writeDetectFile(t, dir, "MyApp/MyAppApp.swift", "@main struct MyAppApp {}") + writeDetectFile(t, dir, "MyApp/ContentView.swift", "struct ContentView {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "MyApp/MyAppApp.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_NoSources_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_React_ViteMountPoint(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + // Vite scaffolds src/main.tsx; without App.tsx the old list fell through to a + // nonexistent src/App.tsx even though the mount point was right there. + writeDetectFile(t, dir, "src/main.tsx", "createRoot()") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "src/main.tsx"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFindFileUnder_SuffixPattern(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp/MyAppApp.swift", "") + + assert.Equal(t, filepath.Join("MyApp/MyAppApp.swift"), findFileUnder(dir, "MyApp", "*App.swift")) + assert.Empty(t, findFileUnder(dir, "MyApp", "*.kt")) +} + +// An empty root must not walk the whole project. +func TestFindFileUnder_EmptyRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "deep/nested/App.swift", "") + + assert.Empty(t, findFileUnder(dir, "", "App.swift")) +} + +// With several targets there is no way to tell an entry point from a helper, so the +// detector must not present an arbitrary pick as found. +func TestFileDetector_Swift_MultipleTargets_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/Alpha/Helper.swift", "struct Helper {}") + writeDetectFile(t, dir, "Sources/Beta/Beta.swift", "@main struct Beta {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_SingleTarget_PrefersTargetNamedFile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + // Helper.swift sorts first, but MyTool.swift is the entry file. + writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "struct Helper {}") + writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "@main struct MyTool {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/MyTool.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestSoleSubdir(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "one/Alpha/a.swift", "") + writeDetectFile(t, dir, "two/Alpha/a.swift", "") + writeDetectFile(t, dir, "two/Beta/b.swift", "") + writeDetectFile(t, dir, "files/a.swift", "") + + assert.Equal(t, filepath.Join("one/Alpha"), soleSubdir(dir, "one")) + assert.Empty(t, soleSubdir(dir, "two"), "two subdirectories is ambiguous") + assert.Empty(t, soleSubdir(dir, "files"), "files are not targets") + assert.Empty(t, soleSubdir(dir, "missing")) } From 225934961a9d1acfa84cf32fea2b7868d9df0845 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 31 Jul 2026 13:57:33 -0400 Subject: [PATCH 3/7] fix(setup): keep the Next.js SDK key out of the browser bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js detection targeted whichever page module happened to exist, and node-server is append-safe, so setup wrote server SDK init — including the SDK key — into app/page.tsx or pages/index.tsx. A page module may carry 'use client' or be imported by something that does, which bundles it for the browser, and nothing in the detector can tell which. Only instrumentation.ts, Next's server-startup hook, is guaranteed to stay server-side, so suggest creating it rather than picking a page. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 12 +++++------- internal/setup/detector_test.go | 15 ++++++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index cb5d85fe..6f9352f4 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -98,16 +98,14 @@ func detectNode(dir string) *DetectResult { // Next.js apps run a Node server (SSR and API routes), so server-side flag // evaluation uses the Node server SDK rather than a browser client SDK. if _, ok := allDeps["next"]; ok { - // instrumentation.ts is Next's server-startup hook, which runs once before - // any request and is the only entry file that suits a server SDK in both - // the App Router and the Pages Router. It is also what we create when the - // project has no suitable file yet. + // Only instrumentation.ts, Next's server-startup hook, is guaranteed to stay + // out of the browser bundle. A page or route module may carry 'use client' or + // be imported by something that does, which would ship the server SDK key to + // the browser, and nothing here can tell which. Suggest creating the hook + // rather than picking a page that happens to exist. ep, exists := entryPoint(dir, "instrumentation.ts", "instrumentation.ts", "instrumentation.js", "src/instrumentation.ts", "src/instrumentation.js", - "app/page.tsx", "src/app/page.tsx", - "pages/index.tsx", "pages/index.ts", "pages/index.js", - "src/index.ts", "src/index.js", ) return &DetectResult{ Language: "JavaScript", diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index 42e25f37..06178c90 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -342,6 +342,8 @@ func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { assert.Contains(t, err.Error(), "could not detect") } +// A page module may carry 'use client' or be imported by something that does, which +// would ship the server SDK key to the browser, so never target one. func TestFileDetector_NextJs_AppRouter_SuggestsInstrumentation(t *testing.T) { dir := t.TempDir() writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^15.0.0"}}`) @@ -352,10 +354,8 @@ func TestFileDetector_NextJs_AppRouter_SuggestsInstrumentation(t *testing.T) { require.NoError(t, err) assert.Equal(t, "node-server", result.SDKID) - // An App Router project has no pages/ or src/index, so the old candidate list - // fell through to a nonexistent index.js at the repo root. - assert.Equal(t, filepath.Join(dir, "app/page.tsx"), result.EntryPoint) - assert.True(t, result.EntryPointExists) + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.False(t, result.EntryPointExists) } func TestFileDetector_NextJs_PrefersExistingInstrumentation(t *testing.T) { @@ -371,7 +371,7 @@ func TestFileDetector_NextJs_PrefersExistingInstrumentation(t *testing.T) { assert.True(t, result.EntryPointExists) } -func TestFileDetector_NextJs_PagesRouter(t *testing.T) { +func TestFileDetector_NextJs_PagesRouter_SuggestsInstrumentation(t *testing.T) { dir := t.TempDir() writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^13.0.0"}}`) writeDetectFile(t, dir, "pages/index.tsx", "export default function Home() {}") @@ -379,8 +379,9 @@ func TestFileDetector_NextJs_PagesRouter(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "pages/index.tsx"), result.EntryPoint) - assert.True(t, result.EntryPointExists) + // pages/* is bundled for the browser, so it is never a server SDK target. + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.False(t, result.EntryPointExists) } func TestFileDetector_NextJs_Empty_SuggestsInstrumentation(t *testing.T) { From 3fefe374144bb030df1e2808b536b36df946ba20 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 31 Jul 2026 14:24:46 -0400 Subject: [PATCH 4/7] docs(setup): cite the sources for the entry-point candidates Each candidate list encodes a claim about where a toolchain puts its entry file. Link the documentation that claim rests on so it can be rechecked when the frameworks move. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 45 ++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 6f9352f4..31f849ea 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -98,11 +98,9 @@ func detectNode(dir string) *DetectResult { // Next.js apps run a Node server (SSR and API routes), so server-side flag // evaluation uses the Node server SDK rather than a browser client SDK. if _, ok := allDeps["next"]; ok { - // Only instrumentation.ts, Next's server-startup hook, is guaranteed to stay - // out of the browser bundle. A page or route module may carry 'use client' or - // be imported by something that does, which would ship the server SDK key to - // the browser, and nothing here can tell which. Suggest creating the hook - // rather than picking a page that happens to exist. + // Entry point: https://nextjs.org/docs/app/guides/instrumentation + // Only the hook is guaranteed to stay out of the browser bundle; a page or + // route module may carry 'use client' and ship the SDK key to the browser. ep, exists := entryPoint(dir, "instrumentation.ts", "instrumentation.ts", "instrumentation.js", "src/instrumentation.ts", "src/instrumentation.js", @@ -118,6 +116,7 @@ func detectNode(dir string) *DetectResult { } if _, ok := allDeps["react-native"]; ok { + // Entry point: https://reactnative.dev/docs/appregistry ep, exists := entryPoint(dir, "index.js", "src/App.tsx", "src/App.jsx", "src/App.js", "src/index.tsx", "src/index.jsx", "src/index.js", @@ -133,9 +132,9 @@ func detectNode(dir string) *DetectResult { } } if _, ok := allDeps["react"]; ok { - // src/main.tsx is where Vite mounts the app and src/index.tsx is where - // Create React App does; either is a better home for the provider than a - // component file, but App.tsx works and is the more familiar edit. + // Vite entry: https://vite.dev/guide/#index-html-and-project-root + // CRA entry: https://create-react-app.dev/docs/folder-structure + // Mounting: https://react.dev/reference/react-dom/client/createRoot ep, exists := entryPoint(dir, "src/App.tsx", "src/App.tsx", "src/App.jsx", "src/App.js", "src/main.tsx", "src/main.jsx", @@ -161,6 +160,8 @@ func detectNode(dir string) *DetectResult { } for _, fw := range jsClientFrameworks { if _, ok := allDeps[fw.dep]; ok { + // Vite entry: https://vite.dev/guide/#index-html-and-project-root + // Angular entry: https://angular.dev/reference/configs/file-structure ep, exists := entryPoint(dir, "src/main.ts", "src/App.tsx", "src/App.jsx", "src/App.js", "src/index.tsx", "src/index.jsx", "src/index.js", @@ -177,6 +178,7 @@ func detectNode(dir string) *DetectResult { } } + // Entry point: https://docs.npmjs.com/cli/v11/configuring-npm/package-json#main ep, exists := entryPoint(dir, "index.js", "src/index.ts", "src/index.js", "index.ts", "index.js", @@ -199,7 +201,7 @@ func detectNodePM(dir string) string { if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { return "yarn" } - // bun.lock is the text lockfile from Bun 1.2 onwards; bun.lockb is the older binary one. + // Lockfiles: https://bun.com/docs/install/lockfile for _, lock := range []string{"bun.lock", "bun.lockb"} { if _, err := os.Stat(filepath.Join(dir, lock)); err == nil { return "bun" @@ -212,6 +214,7 @@ func detectGo(dir string) *DetectResult { if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { return nil } + // Entry point: https://go.dev/ref/spec#Program_execution ep, exists := entryPoint(dir, "main.go", "main.go", "cmd/main.go") return &DetectResult{ Language: "Go", @@ -225,6 +228,8 @@ func detectGo(dir string) *DetectResult { func detectPython(dir string) *DetectResult { for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"} { if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + // Django entry: https://docs.djangoproject.com/en/stable/ref/django-admin/ + // Flask entry: https://flask.palletsprojects.com/en/stable/quickstart/ ep, exists := entryPoint(dir, "main.py", "src/main.py", "manage.py", "app.py", "main.py", ) @@ -243,6 +248,10 @@ func detectPython(dir string) *DetectResult { // detectPythonPM identifies the tool that manages the project's dependencies, so // callers install into the project rather than running pip against whatever // interpreter happens to be on PATH. +// +// https://docs.astral.sh/uv/concepts/projects/layout/ +// https://pipenv.pypa.io/en/latest/ +// https://python-poetry.org/docs/pyproject/ func detectPythonPM(dir string) string { if _, err := os.Stat(filepath.Join(dir, "uv.lock")); err == nil { return "uv" @@ -274,12 +283,12 @@ func detectRuby(dir string) *DetectResult { return nil } } - // A Gemfile means Bundler manages the project's gems, so the SDK has to be - // added to the Gemfile rather than installed into the global gem set. + // Gemfile: https://bundler.io/guides/gemfile.html pm := "gem" if _, err := os.Stat(filepath.Join(dir, "Gemfile")); err == nil { pm = "bundle" } + // config.ru: https://github.com/rack/rack/blob/main/SPEC.rdoc ep, exists := entryPoint(dir, "main.rb", "config.ru", "app.rb", "main.rb") return &DetectResult{ Language: "Ruby", @@ -297,7 +306,7 @@ func detectJava(dir string) *DetectResult { if indicator == "pom.xml" { pm = "mvn" } - // Android projects use Gradle but are distinguished by AndroidManifest.xml. + // Manifest: https://developer.android.com/guide/topics/manifest/manifest-intro for _, manifest := range []string{ "app/src/main/AndroidManifest.xml", "src/main/AndroidManifest.xml", @@ -305,8 +314,8 @@ func detectJava(dir string) *DetectResult { if _, err := os.Stat(filepath.Join(dir, manifest)); err != nil { continue } - // The manifest tells us which source root this project uses; the - // activity itself lives under a package directory, so search for it + // Entry point: https://developer.android.com/reference/android/app/Activity + // The activity lives under a package directory, so search for it // rather than guessing the package name. srcRoot := strings.TrimSuffix(manifest, "/AndroidManifest.xml") ep, exists := entryPoint(dir, srcRoot+"/java/MainActivity.kt", @@ -321,6 +330,8 @@ func detectJava(dir string) *DetectResult { EntryPointExists: exists, } } + // Gradle layout: https://docs.gradle.org/current/userguide/building_java_projects.html + // Maven layout: https://maven.apache.org/guides/introduction/introduction-to-the-pom.html ep, exists := entryPoint(dir, "src/main/java/Main.java", findFileUnder(dir, "src/main/java", "Main.java", "Application.java", "App.java"), ) @@ -375,6 +386,9 @@ func detectSwift(dir string) *DetectResult { // project. Any-name matches are confined to a package with a single target, where // the entry file is named after that target; with several targets there is no way to // tell an entry point from a helper. +// +// App struct: https://developer.apple.com/documentation/swiftui/app +// Package targets: https://developer.apple.com/documentation/packagedescription/target func swiftEntryCandidates(dir, appRoot string) []string { candidates := []string{ "App.swift", "ContentView.swift", "AppDelegate.swift", @@ -413,6 +427,8 @@ func soleSubdir(dir, root string) string { // xcodeAppRoot returns the source directory an Xcode project keeps its app code in, // which the templates name after the project (MyApp.xcodeproj alongside MyApp/). // Returns an empty string when dir holds no Xcode project. +// +// https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app func xcodeAppRoot(dir string) string { matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) if len(matches) == 0 { @@ -425,6 +441,7 @@ func detectDotnet(dir string) *DetectResult { for _, pattern := range []string{"*.csproj", "*.sln"} { matches, _ := filepath.Glob(filepath.Join(dir, pattern)) if len(matches) > 0 { + // Entry point: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/startup ep, exists := entryPoint(dir, "Program.cs", "Program.cs", "Startup.cs", "src/Program.cs", ) From c276645a88ee865752eff0489998612136f50429 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 31 Jul 2026 16:00:33 -0400 Subject: [PATCH 5/7] fix(setup): detect backend manifests before package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root package.json is often only build tooling — Rails with jsbundling, Django with Tailwind, a Go binary published to npm — so preferring Node whenever one parsed meant those projects were handed the Node SDK. This repo hit it too. Confine the Sources/ search to single-target Swift packages. Across several targets there is no way to tell an executable's entry file from a library's, so an arbitrary hit was reported as found. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 46 ++++++++++----------- internal/setup/detector_test.go | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 31f849ea..293d4a3e 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -46,27 +46,22 @@ var _ Detector = FileDetector{} // Detect scans dir for known project files and returns a DetectResult with language, // framework, SDK ID, package manager, and a suggested entry point file. // Returns an error if the project type cannot be determined. +// A root package.json is often only build tooling — Rails with jsbundling, Django +// with Tailwind, a Go binary published to npm — so the backend manifests are +// checked first and Node claims the project only when it is the sole manifest. func (FileDetector) Detect(dir string) (*DetectResult, error) { - if result := detectNode(dir); result != nil { - return result, nil - } - if result := detectGo(dir); result != nil { - return result, nil - } - if result := detectPython(dir); result != nil { - return result, nil - } - if result := detectRuby(dir); result != nil { - return result, nil - } - if result := detectJava(dir); result != nil { - return result, nil - } - if result := detectSwift(dir); result != nil { - return result, nil - } - if result := detectDotnet(dir); result != nil { - return result, nil + for _, detect := range []func(string) *DetectResult{ + detectGo, + detectPython, + detectRuby, + detectJava, + detectSwift, + detectDotnet, + detectNode, + } { + if result := detect(dir); result != nil { + return result, nil + } } return nil, errors.New("could not detect project language from directory; try specifying --sdk-id manually") } @@ -393,13 +388,14 @@ func swiftEntryCandidates(dir, appRoot string) []string { candidates := []string{ "App.swift", "ContentView.swift", "AppDelegate.swift", findFileUnder(dir, appRoot, "*App.swift", "ContentView.swift", "AppDelegate.swift"), - findFileUnder(dir, "Sources", "main.swift", "*App.swift"), } + // Searching Sources/ at all is confined to a single-target package. Across + // several targets there is no way to tell an executable's entry file from a + // library's, so report a suggestion instead of an arbitrary hit. if target := soleSubdir(dir, "Sources"); target != "" { - candidates = append(candidates, - findFileUnder(dir, target, filepath.Base(target)+".swift"), - findFileUnder(dir, target, "*.swift"), - ) + candidates = append(candidates, findFileUnder(dir, target, + "main.swift", filepath.Base(target)+".swift", "*App.swift", "*.swift", + )) } return candidates } diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index 06178c90..cb48d976 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -720,3 +720,75 @@ func TestSoleSubdir(t *testing.T) { assert.Empty(t, soleSubdir(dir, "files"), "files are not targets") assert.Empty(t, soleSubdir(dir, "missing")) } + +// A root package.json is often only build tooling, so a backend manifest wins. +func TestFileDetector_Polyglot_BackendManifestWins(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantSDK string + }{ + {"rails with jsbundling", map[string]string{ + "Gemfile": "source 'https://rubygems.org'\ngem 'rails'\n", "package.json": `{"dependencies":{"esbuild":"0.20.0"}}`, + }, "ruby-server-sdk"}, + {"django with tailwind", map[string]string{ + "requirements.txt": "Django==5.0\n", "package.json": `{"devDependencies":{"tailwindcss":"3.4.0"}}`, + }, "python-server-sdk"}, + {"go binary published to npm", map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", "package.json": `{"name":"app-cli"}`, + }, "go-server-sdk"}, + {"dotnet with npm assets", map[string]string{ + "App.csproj": "", "package.json": `{"devDependencies":{"vite":"5.0.0"}}`, + }, "dotnet-server-sdk"}, + // package.json is the only manifest, so Node still claims it. + {"plain next.js", map[string]string{ + "package.json": `{"dependencies":{"next":"15.0.0"}}`, + }, "node-server"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tt.files { + writeDetectFile(t, dir, name, content) + } + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, tt.wantSDK, result.SDKID) + }) + } +} + +// Searching Sources/ is confined to single-target packages, so neither main.swift +// nor a *App.swift in one of several targets may be reported as found. +func TestFileDetector_Swift_MultipleTargets_NeverReportsFound(t *testing.T) { + for _, entry := range []string{"Sources/Beta/main.swift", "Sources/Zeta/ZetaApp.swift", "Sources/Beta/Beta.swift"} { + t.Run(entry, func(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/Alpha/Helper.swift", "struct Helper {}") + writeDetectFile(t, dir, entry, "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint) + assert.False(t, result.EntryPointExists) + }) + } +} + +func TestFileDetector_Swift_SingleTarget_PrefersMainSwift(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "") + writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "") + writeDetectFile(t, dir, "Sources/MyTool/main.swift", "print(1)") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/main.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} From 0b6163979d1f8fd4af711fcc35b8a01e1c4df623 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 14:48:05 -0400 Subject: [PATCH 6/7] test(setup): assert the whole result per project shape The existing tests check one or two fields each, so a field detection stops populating passes as long as the SDK id stays right. Compare the full DetectResult across the project layouts real toolchains produce. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector_shapes_test.go | 375 +++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 internal/setup/detector_shapes_test.go diff --git a/internal/setup/detector_shapes_test.go b/internal/setup/detector_shapes_test.go new file mode 100644 index 00000000..bc8302b7 --- /dev/null +++ b/internal/setup/detector_shapes_test.go @@ -0,0 +1,375 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// projectShape is a real-world project layout reduced to the files detection reads. +// want.EntryPoint is relative to the materialized directory and joined before the +// comparison. +type projectShape struct { + name string + files map[string]string + dirs []string + want DetectResult + wantErr bool +} + +// pkgJSON builds a package.json listing deps as dependencies; a dep prefixed with +// "dev:" goes to devDependencies instead. +func pkgJSON(deps ...string) string { + prod, dev := "", "" + for _, d := range deps { + if name, ok := cutDevPrefix(d); ok { + dev += `"` + name + `":"1.0.0",` + continue + } + prod += `"` + d + `":"1.0.0",` + } + return `{"dependencies":{` + trimComma(prod) + `},"devDependencies":{` + trimComma(dev) + `}}` +} + +func cutDevPrefix(d string) (string, bool) { + if len(d) > 4 && d[:4] == "dev:" { + return d[4:], true + } + return "", false +} + +func trimComma(s string) string { + if s == "" { + return s + } + return s[:len(s)-1] +} + +// TestFileDetector_ProjectShapes asserts the whole DetectResult for each layout, so a +// field the detector stops populating fails here even when the SDK id stays right. +func TestFileDetector_ProjectShapes(t *testing.T) { + shapes := []projectShape{ + // --- JavaScript / Node --- + { + name: "next app router", + files: map[string]string{ + "package.json": pkgJSON("next", "react"), + "next.config.ts": "export default {}", + "app/layout.tsx": "export default function Layout() {}", + "app/page.tsx": "export default function Page() {}", + "tsconfig.json": "{}", + "next-env.d.ts": "", + }, + // A page module may be browser-bundled, which would ship the SDK key. + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "next src dir", + files: map[string]string{ + "package.json": pkgJSON("next", "react"), + "src/app/page.tsx": "export default function Page() {}", + "src/app/layout.tsx": "export default function Layout() {}", + }, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "next src instrumentation", + files: map[string]string{ + "package.json": pkgJSON("next"), + "src/instrumentation.ts": "export function register() {}", + "src/app/page.tsx": "export default function Page() {}", + }, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/instrumentation.ts", EntryPointExists: true}, + }, + { + name: "next root instrumentation", + files: map[string]string{ + "package.json": pkgJSON("next"), + "instrumentation.ts": "export function register() {}", + "app/page.tsx": "export default function Page() {}", + }, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts", EntryPointExists: true}, + }, + { + name: "next pages router", + files: map[string]string{"package.json": pkgJSON("next", "react"), "pages/index.tsx": "export default function Home() {}"}, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "next bare", + files: map[string]string{"package.json": pkgJSON("next")}, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "node bun", + files: map[string]string{"package.json": pkgJSON("hono"), "bun.lockb": ""}, + want: DetectResult{Language: "JavaScript", PackageManager: "bun", SDKID: "node-server", EntryPoint: "index.js"}, + }, + { + name: "node npm", + files: map[string]string{"package.json": pkgJSON("express"), "package-lock.json": "{}", "index.js": "// entry"}, + want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "index.js", EntryPointExists: true}, + }, + { + name: "node pnpm typescript", + files: map[string]string{"package.json": pkgJSON("express"), "pnpm-lock.yaml": "", "src/index.ts": "// entry"}, + want: DetectResult{Language: "JavaScript", PackageManager: "pnpm", SDKID: "node-server", EntryPoint: "src/index.ts", EntryPointExists: true}, + }, + { + name: "node yarn server file", + files: map[string]string{"package.json": pkgJSON("fastify"), "yarn.lock": "", "server.js": "// entry"}, + want: DetectResult{Language: "JavaScript", PackageManager: "yarn", SDKID: "node-server", EntryPoint: "server.js", EntryPointExists: true}, + }, + { + name: "react vite mount point only", + files: map[string]string{"package.json": pkgJSON("react", "dev:vite"), "src/main.tsx": "createRoot()"}, + want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "npm", SDKID: "react-client-sdk", EntryPoint: "src/main.tsx", EntryPointExists: true}, + }, + { + name: "react vite yarn", + files: map[string]string{"package.json": pkgJSON("react", "dev:vite"), "yarn.lock": "", "src/App.tsx": "// App"}, + want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "yarn", SDKID: "react-client-sdk", EntryPoint: "src/App.tsx", EntryPointExists: true}, + }, + { + name: "react vite full scaffold prefers App over mount", + files: map[string]string{ + "package.json": pkgJSON("react", "react-dom", "dev:vite", "dev:@vitejs/plugin-react"), + "index.html": "
", + "vite.config.ts": "export default {}", + "src/App.tsx": "// App", + "src/main.tsx": "createRoot()", + "src/index.css": "", + }, + want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "npm", SDKID: "react-client-sdk", EntryPoint: "src/App.tsx", EntryPointExists: true}, + }, + { + name: "react native", + files: map[string]string{"package.json": pkgJSON("react", "react-native"), "App.tsx": "// App", "index.js": "AppRegistry.registerComponent()"}, + want: DetectResult{Language: "JavaScript", Framework: "React Native", PackageManager: "npm", SDKID: "react-native", EntryPoint: "App.tsx", EntryPointExists: true}, + }, + { + name: "vue", + files: map[string]string{"package.json": pkgJSON("vue"), "src/main.ts": "createApp()"}, + want: DetectResult{Language: "JavaScript", Framework: "Vue", PackageManager: "npm", SDKID: "js-client-sdk", EntryPoint: "src/main.ts", EntryPointExists: true}, + }, + { + name: "svelte", + files: map[string]string{"package.json": pkgJSON("svelte"), "src/main.ts": "new App()"}, + want: DetectResult{Language: "JavaScript", Framework: "Svelte", PackageManager: "npm", SDKID: "js-client-sdk", EntryPoint: "src/main.ts", EntryPointExists: true}, + }, + + // --- Go --- + { + name: "go single main", + files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.22\n", "main.go": "package main\n"}, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go", EntryPointExists: true}, + }, + { + name: "go single cmd binary", + files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.22\n", "cmd/server/main.go": "package main\n"}, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go"}, + }, + { + name: "go several cmd binaries", + files: map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", + "cmd/server/main.go": "package main\n", + "cmd/worker/main.go": "package main\n", + }, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go"}, + }, + + // --- Python --- + { + name: "python pipenv", + files: map[string]string{"Pipfile": "[packages]\n", "main.py": "# main"}, + want: DetectResult{Language: "Python", PackageManager: "pipenv", SDKID: "python-server-sdk", EntryPoint: "main.py", EntryPointExists: true}, + }, + { + name: "python poetry", + files: map[string]string{"pyproject.toml": "[tool.poetry]\nname = \"app\"\n", "app.py": "# app"}, + want: DetectResult{Language: "Python", PackageManager: "poetry", SDKID: "python-server-sdk", EntryPoint: "app.py", EntryPointExists: true}, + }, + { + name: "python uv lockfile", + files: map[string]string{"pyproject.toml": "[project]\nname = \"app\"\n", "uv.lock": "version = 1\n"}, + want: DetectResult{Language: "Python", PackageManager: "uv", SDKID: "python-server-sdk", EntryPoint: "main.py"}, + }, + { + name: "python requirements", + files: map[string]string{"requirements.txt": "flask\n", "src/main.py": "# main"}, + want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "src/main.py", EntryPointExists: true}, + }, + + // --- Ruby --- + { + name: "ruby bundler rack", + files: map[string]string{"Gemfile": "source 'https://rubygems.org'\n", "Gemfile.lock": "", "config.ru": "run App"}, + want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "config.ru", EntryPointExists: true}, + }, + { + name: "ruby gemspec only", + files: map[string]string{"mygem.gemspec": "Gem::Specification.new\n"}, + want: DetectResult{Language: "Ruby", PackageManager: "gem", SDKID: "ruby-server-sdk", EntryPoint: "main.rb"}, + }, + + // --- Java / Android --- + { + name: "java maven", + files: map[string]string{"pom.xml": "", "src/main/java/com/example/app/Application.java": "class Application {}"}, + want: DetectResult{Language: "Java", PackageManager: "mvn", SDKID: "java-server-sdk", EntryPoint: "src/main/java/com/example/app/Application.java", EntryPointExists: true}, + }, + { + name: "java gradle", + files: map[string]string{"build.gradle": "plugins { id 'java' }", "src/main/java/com/example/Main.java": "class Main {}"}, + want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "java-server-sdk", EntryPoint: "src/main/java/com/example/Main.java", EntryPointExists: true}, + }, + { + name: "android app module kotlin", + files: map[string]string{ + "build.gradle.kts": "plugins { id(\"com.android.application\") }", + "settings.gradle.kts": "", + "app/src/main/AndroidManifest.xml": "", + "app/src/main/java/com/example/myapp/MainActivity.kt": "class MainActivity", + }, + want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "android", EntryPoint: "app/src/main/java/com/example/myapp/MainActivity.kt", EntryPointExists: true}, + }, + { + name: "android single module java", + files: map[string]string{ + "build.gradle": "plugins { id 'com.android.application' }", + "src/main/AndroidManifest.xml": "", + "src/main/java/com/example/MainActivity.java": "class MainActivity {}", + }, + want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "android", EntryPoint: "src/main/java/com/example/MainActivity.java", EntryPointExists: true}, + }, + + // --- Swift --- + { + name: "swift package single target", + files: map[string]string{"Package.swift": "// swift-tools-version:5.9", "Sources/MyTool/MyTool.swift": "print(1)", "Tests/MyToolTests/MyToolTests.swift": ""}, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "Sources/MyTool/MyTool.swift", EntryPointExists: true}, + }, + { + name: "swift package sources without target dir", + files: map[string]string{"Package.swift": "// swift-tools-version:5.9", "Sources/main.swift": "print(1)"}, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "App.swift"}, + }, + { + name: "swift package several targets", + files: map[string]string{ + "Package.swift": "// swift-tools-version:5.9", + "Sources/Alpha/Helper.swift": "struct Helper {}", + "Sources/Beta/main.swift": "print(1)", + }, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "App.swift"}, + }, + { + name: "swift xcode project", + files: map[string]string{"MyApp/MyAppApp.swift": "@main struct MyAppApp {}", "MyApp/ContentView.swift": "struct ContentView {}"}, + dirs: []string{"MyApp.xcodeproj"}, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "MyApp/MyAppApp.swift", EntryPointExists: true}, + }, + { + name: "swift cocoapods", + files: map[string]string{"Podfile": "platform :ios, '14.0'"}, + want: DetectResult{Language: "Swift", PackageManager: "cocoapods", SDKID: "swift-client-sdk", EntryPoint: "App.swift"}, + }, + + // --- C# --- + { + name: "dotnet csproj", + files: map[string]string{"MyApp.csproj": "", "Program.cs": "// entry"}, + want: DetectResult{Language: "C#", PackageManager: "dotnet", SDKID: "dotnet-server-sdk", EntryPoint: "Program.cs", EntryPointExists: true}, + }, + { + name: "dotnet solution with nested project", + files: map[string]string{"MyApp.sln": "", "src/MyApp/MyApp.csproj": "", "src/MyApp/Program.cs": "// entry"}, + want: DetectResult{Language: "C#", PackageManager: "dotnet", SDKID: "dotnet-server-sdk", EntryPoint: "Program.cs"}, + }, + + // --- Polyglot: a root package.json is usually build tooling --- + { + name: "rails with jsbundling", + files: map[string]string{ + "Gemfile": "source 'https://rubygems.org'\ngem 'rails'\n", + "config.ru": "run Rails.application", + "package.json": pkgJSON("esbuild"), + }, + want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "config.ru", EntryPointExists: true}, + }, + { + name: "django with tailwind", + files: map[string]string{ + "requirements.txt": "Django==5.0\n", + "manage.py": "# manage", + "package.json": pkgJSON("dev:tailwindcss"), + }, + want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "manage.py", EntryPointExists: true}, + }, + { + name: "go binary published to npm", + files: map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", + "main.go": "package main\n", + "package.json": `{"name":"app-cli"}`, + }, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go", EntryPointExists: true}, + }, + { + name: "next app carrying a Gemfile", + files: map[string]string{ + "package.json": pkgJSON("next"), + "Gemfile": "source 'https://rubygems.org'\ngem 'rubocop'\n", + }, + // Accepted cost of preferring the backend manifest. The wizard lets the + // user override the SDK, and --sdk-id exists. + want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "main.rb"}, + }, + { + name: "next app carrying a ruff config", + files: map[string]string{ + "package.json": pkgJSON("next"), + "pyproject.toml": "[tool.ruff]\nline-length = 100\n", + }, + want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "main.py"}, + }, + + // --- No manifest at all --- + {name: "empty directory", wantErr: true}, + {name: "malformed package.json", files: map[string]string{"package.json": "not json {{{"}, wantErr: true}, + } + + for _, shape := range shapes { + t.Run(shape.name, func(t *testing.T) { + dir := materialize(t, shape) + + result, err := FileDetector{}.Detect(dir) + + if shape.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") + return + } + require.NoError(t, err) + want := shape.want + want.EntryPoint = filepath.Join(dir, want.EntryPoint) + assert.Equal(t, want, *result) + }) + } +} + +func materialize(t *testing.T, shape projectShape) string { + t.Helper() + dir := t.TempDir() + for _, d := range shape.dirs { + require.NoError(t, os.MkdirAll(filepath.Join(dir, d), 0755)) + } + for name, content := range shape.files { + writeDetectFile(t, dir, name, content) + } + return dir +} From b5d737c707a6c197b2ea1c7781ceb7b742718d9a Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 15:45:18 -0400 Subject: [PATCH 7/7] fix(setup): find the src/main entry a Node app bootstraps from NestJS and similar apps start from src/main.ts, which the candidate list skipped, so detection suggested a nonexistent index.js. node-server appends to the entry file, so setup created that index.js and left the real entry point without the SDK. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 2 ++ internal/setup/detector_shapes_test.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 293d4a3e..a450fc7e 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -174,8 +174,10 @@ func detectNode(dir string) *DetectResult { } // Entry point: https://docs.npmjs.com/cli/v11/configuring-npm/package-json#main + // NestJS bootstraps from src/main.ts: https://docs.nestjs.com/first-steps ep, exists := entryPoint(dir, "index.js", "src/index.ts", "src/index.js", + "src/main.ts", "src/main.js", "index.ts", "index.js", "server.ts", "server.js", "app.ts", "app.js", diff --git a/internal/setup/detector_shapes_test.go b/internal/setup/detector_shapes_test.go index bc8302b7..56df04eb 100644 --- a/internal/setup/detector_shapes_test.go +++ b/internal/setup/detector_shapes_test.go @@ -118,6 +118,16 @@ func TestFileDetector_ProjectShapes(t *testing.T) { files: map[string]string{"package.json": pkgJSON("express"), "pnpm-lock.yaml": "", "src/index.ts": "// entry"}, want: DetectResult{Language: "JavaScript", PackageManager: "pnpm", SDKID: "node-server", EntryPoint: "src/index.ts", EntryPointExists: true}, }, + { + name: "nest bootstraps from src/main.ts", + files: map[string]string{"package.json": pkgJSON("@nestjs/core", "@nestjs/common"), "src/main.ts": "bootstrap()"}, + want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/main.ts", EntryPointExists: true}, + }, + { + name: "node prefers src/index over src/main", + files: map[string]string{"package.json": pkgJSON("express"), "src/index.ts": "// entry", "src/main.ts": "// other"}, + want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/index.ts", EntryPointExists: true}, + }, { name: "node yarn server file", files: map[string]string{"package.json": pkgJSON("fastify"), "yarn.lock": "", "server.js": "// entry"},