diff --git a/internal/setup/detector.go b/internal/setup/detector.go
new file mode 100644
index 00000000..a450fc7e
--- /dev/null
+++ b/internal/setup/detector.go
@@ -0,0 +1,533 @@
+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.
+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"`
+ // 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,
+// 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.
+// 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) {
+ 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")
+}
+
+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 {
+ // 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",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: "Next.js",
+ PackageManager: pm,
+ SDKID: "node-server",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+
+ 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",
+ "App.tsx", "App.js", "index.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: "React Native",
+ PackageManager: pm,
+ SDKID: "react-native",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ if _, ok := allDeps["react"]; ok {
+ // 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",
+ "src/index.tsx", "src/index.jsx", "src/index.js",
+ "index.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: "React",
+ PackageManager: pm,
+ SDKID: "react-client-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ 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 {
+ // 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",
+ "src/main.ts", "src/main.js", "index.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: fw.framework,
+ PackageManager: pm,
+ SDKID: "js-client-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+
+ // 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",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ PackageManager: pm,
+ SDKID: "node-server",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+}
+
+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"
+ }
+ // 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"
+ }
+ }
+ return "npm"
+}
+
+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",
+ 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", "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",
+ )
+ return &DetectResult{
+ 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.
+//
+// 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"
+ }
+ 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"} {
+ 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
+ }
+ }
+ // 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",
+ PackageManager: pm,
+ SDKID: "ruby-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+}
+
+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"
+ }
+ // Manifest: https://developer.android.com/guide/topics/manifest/manifest-intro
+ for _, manifest := range []string{
+ "app/src/main/AndroidManifest.xml",
+ "src/main/AndroidManifest.xml",
+ } {
+ if _, err := os.Stat(filepath.Join(dir, manifest)); err != nil {
+ continue
+ }
+ // 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",
+ 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,
+ }
+ }
+ // 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"),
+ )
+ return &DetectResult{
+ Language: "Java",
+ PackageManager: pm,
+ SDKID: "java-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+ return nil
+}
+
+func detectSwift(dir string) *DetectResult {
+ pm := "spm"
+ 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: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+ if appRoot := xcodeAppRoot(dir); appRoot != "" {
+ ep, exists := swiftEntryPoint(appRoot)
+ return &DetectResult{
+ 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.
+//
+// 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",
+ findFileUnder(dir, appRoot, "*App.swift", "ContentView.swift", "AppDelegate.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,
+ "main.swift", filepath.Base(target)+".swift", "*App.swift", "*.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.
+//
+// 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 {
+ 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 {
+ // 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",
+ )
+ return &DetectResult{
+ Language: "C#",
+ PackageManager: "dotnet",
+ SDKID: "dotnet-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+ 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", 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"},
+}
+
+// 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 ""
+ }
+ 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 ""
+}
diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go
new file mode 100644
index 00000000..a38349c6
--- /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, "bundle", 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_shapes_test.go b/internal/setup/detector_shapes_test.go
new file mode 100644
index 00000000..56df04eb
--- /dev/null
+++ b/internal/setup/detector_shapes_test.go
@@ -0,0 +1,385 @@
+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: "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"},
+ 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
+}
diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go
new file mode 100644
index 00000000..cb48d976
--- /dev/null
+++ b/internal/setup/detector_test.go
@@ -0,0 +1,794 @@
+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)
+ assert.True(t, result.EntryPointExists)
+}
+
+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)
+ assert.True(t, result.EntryPointExists)
+}
+
+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)
+ assert.True(t, result.EntryPointExists)
+}
+
+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)
+ assert.True(t, result.EntryPointExists)
+}
+
+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", 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", 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)
+ assert.True(t, result.EntryPointExists)
+}
+
+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)
+ 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) {
+ 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")
+}
+
+// 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"}}`)
+ 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)
+ assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint)
+ assert.False(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_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() {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ // 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) {
+ 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 TestEntryPoint_NoCandidateExists_ReturnsFallback(t *testing.T) {
+ dir := t.TempDir()
+
+ 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 TestEntryPoint_MatchesFirstExisting(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "second.go", "")
+ writeDetectFile(t, dir, "first.go", "")
+
+ 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"))
+}
+
+// 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)
+}