From 433d3bf3baab005675544e151bff8d2cfbdb6a11 Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 11:43:30 -0500 Subject: [PATCH 1/9] feat: add detect.go with ProjectInfo struct and helper functions Co-Authored-By: Claude Opus 4.6 --- detect.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 detect.go diff --git a/detect.go b/detect.go new file mode 100644 index 0000000..58d0f21 --- /dev/null +++ b/detect.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "regexp" + "strings" +) + +// ProjectInfo holds detected project metadata for template placeholder replacement. +type ProjectInfo struct { + Name string + Description string + Framework string + LanguageTemplate string + TestCommand string + TypecheckCommand string + BuildCommand string + KeyDirectories []string +} + +// packageJSON is a minimal struct for parsing package.json fields. +type packageJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Scripts map[string]string `json:"scripts"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +func mergeMaps(a, b map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range a { + result[k] = v + } + for k, v := range b { + result[k] = v + } + return result +} + +func hasKeyPrefix(m map[string]string, prefix string) bool { + for k := range m { + if strings.HasPrefix(k, prefix) { + return true + } + } + return false +} + +// extractTOMLString extracts a string value from a simple TOML `key = "value"` line. +// Only handles double-quoted values on a single line. Sufficient for name/description fields. +func extractTOMLString(line string) string { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "" + } + return strings.Trim(strings.TrimSpace(parts[1]), "\"") +} + +func formatKeyDirectories(dirs []string) string { + if len(dirs) == 0 { + return "" + } + var lines []string + for _, d := range dirs { + lines = append(lines, fmt.Sprintf("- `%s`", d)) + } + return strings.Join(lines, "\n") +} + +// placeholderRe matches {{PLACEHOLDER}} patterns in templates. +var placeholderRe = regexp.MustCompile(`\{\{[A-Z_]+\}\}`) From e302af4a4e8584675ef22009e4fbe825a6099dc4 Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 11:44:22 -0500 Subject: [PATCH 2/9] feat: implement project detection heuristics for all frameworks Co-Authored-By: Claude Opus 4.6 --- detect.go | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/detect.go b/detect.go index 58d0f21..7d4335e 100644 --- a/detect.go +++ b/detect.go @@ -1,7 +1,10 @@ package main import ( + "encoding/json" "fmt" + "os" + "path/filepath" "regexp" "strings" ) @@ -70,3 +73,282 @@ func formatKeyDirectories(dirs []string) string { // placeholderRe matches {{PLACEHOLDER}} patterns in templates. var placeholderRe = regexp.MustCompile(`\{\{[A-Z_]+\}\}`) + +func detectKeyDirectories(dir string) []string { + candidates := []string{ + "src", "lib", "app", "cmd", "internal", "pkg", + "api", "server", "client", "web", "frontend", "backend", + "tests", "test", "spec", "e2e", + "docs", "config", "scripts", "migrations", "public", + "resources", "inertia", + } + var found []string + for _, name := range candidates { + info, err := os.Stat(filepath.Join(dir, name)) + if err == nil && info.IsDir() { + found = append(found, name+"/") + } + } + return found +} + +func detectGoProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Go", + LanguageTemplate: "go.md", + TestCommand: "go test ./...", + BuildCommand: "go build ./...", + } + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + return info + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + modulePath := strings.TrimSpace(strings.TrimPrefix(line, "module ")) + parts := strings.Split(modulePath, "/") + info.Name = parts[len(parts)-1] + break + } + } + return info +} + +func detectRustProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Rust", + LanguageTemplate: "rust.md", + TestCommand: "cargo test", + BuildCommand: "cargo build", + } + data, err := os.ReadFile(filepath.Join(dir, "Cargo.toml")) + if err != nil { + return info + } + inPackage := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "[package]" { + inPackage = true + continue + } + if strings.HasPrefix(trimmed, "[") { + inPackage = false + continue + } + if !inPackage { + continue + } + if strings.HasPrefix(trimmed, "name") { + info.Name = extractTOMLString(trimmed) + } else if strings.HasPrefix(trimmed, "description") { + info.Description = extractTOMLString(trimmed) + } + } + return info +} + +func detectPythonProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Python", + LanguageTemplate: "python.md", + TestCommand: "pytest", + TypecheckCommand: "mypy .", + } + data, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + if err != nil { + return info + } + inProject := false + inPoetry := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "[project]" { + inProject = true + inPoetry = false + continue + } + if trimmed == "[tool.poetry]" { + inPoetry = true + inProject = false + continue + } + if strings.HasPrefix(trimmed, "[") { + inProject = false + inPoetry = false + continue + } + if inProject || inPoetry { + if strings.HasPrefix(trimmed, "name") { + info.Name = extractTOMLString(trimmed) + } else if strings.HasPrefix(trimmed, "description") { + info.Description = extractTOMLString(trimmed) + } + } + } + return info +} + +func detectRubyProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Ruby", + LanguageTemplate: "ruby.md", + TestCommand: "bundle exec rspec", + BuildCommand: "bundle exec rake build", + } + data, err := os.ReadFile(filepath.Join(dir, "Gemfile")) + if err != nil { + return info + } + content := string(data) + if strings.Contains(content, "'rails'") || strings.Contains(content, "\"rails\"") { + info.Framework = "Rails" + info.TestCommand = "bundle exec rspec" + info.BuildCommand = "bundle exec rails assets:precompile" + } + return info +} + +func detectPHPProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "PHP", + LanguageTemplate: "php.md", + TestCommand: "vendor/bin/phpunit", + TypecheckCommand: "vendor/bin/phpstan analyse", + BuildCommand: "composer install", + } + data, err := os.ReadFile(filepath.Join(dir, "composer.json")) + if err != nil { + return info + } + var composer struct { + Name string `json:"name"` + Description string `json:"description"` + Require map[string]string `json:"require"` + } + if err := json.Unmarshal(data, &composer); err != nil { + return info + } + info.Name = composer.Name + info.Description = composer.Description + if _, ok := composer.Require["laravel/framework"]; ok { + info.Framework = "Laravel" + info.TestCommand = "php artisan test" + } + return info +} + +func detectNodeProject(dir string) ProjectInfo { + data, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return ProjectInfo{} + } + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return ProjectInfo{} + } + + info := ProjectInfo{ + Name: pkg.Name, + Description: pkg.Description, + } + + allDeps := mergeMaps(pkg.Dependencies, pkg.DevDependencies) + + // Priority order: most specific first. Next.js MUST come before React + // because Next.js projects always have react as a dependency too. + if hasKeyPrefix(allDeps, "@adonisjs/") { + info.Framework = "AdonisJS" + info.LanguageTemplate = "adonisjs.md" + info.TestCommand = "node ace test" + info.TypecheckCommand = "npx tsc --noEmit" + info.BuildCommand = "node ace build" + } else if _, ok := allDeps["svelte"]; ok { + info.Framework = "Svelte" + info.LanguageTemplate = "svelte.md" + info.TestCommand = "npm test" + info.TypecheckCommand = "npx svelte-check" + info.BuildCommand = "npm run build" + } else if _, ok := allDeps["next"]; ok { + info.Framework = "Next.js" + info.LanguageTemplate = "react.md" + info.TestCommand = "npm test" + info.TypecheckCommand = "npx tsc --noEmit" + info.BuildCommand = "npm run build" + } else if _, ok := allDeps["react"]; ok { + info.Framework = "React" + info.LanguageTemplate = "react.md" + info.TestCommand = "npm test" + info.TypecheckCommand = "npx tsc --noEmit" + info.BuildCommand = "npm run build" + } else if _, ok := allDeps["express"]; ok { + info.Framework = "Express" + info.LanguageTemplate = "nodejs.md" + info.TestCommand = "npm test" + info.TypecheckCommand = "npx tsc --noEmit" + info.BuildCommand = "npm run build" + } else { + info.Framework = "Node.js" + info.LanguageTemplate = "nodejs.md" + info.TestCommand = "npm test" + info.TypecheckCommand = "npx tsc --noEmit" + info.BuildCommand = "npm run build" + } + + return overrideFromScripts(info, pkg.Scripts) +} + +func overrideFromScripts(info ProjectInfo, scripts map[string]string) ProjectInfo { + if scripts == nil { + return info + } + if _, ok := scripts["test"]; ok { + info.TestCommand = "npm test" + } else if _, ok := scripts["test:unit"]; ok { + info.TestCommand = "npm run test:unit" + } + if _, ok := scripts["typecheck"]; ok { + info.TypecheckCommand = "npm run typecheck" + } else if _, ok := scripts["type-check"]; ok { + info.TypecheckCommand = "npm run type-check" + } else if _, ok := scripts["check-types"]; ok { + info.TypecheckCommand = "npm run check-types" + } + if _, ok := scripts["build"]; ok { + info.BuildCommand = "npm run build" + } + return info +} + +func detectProject(dir string) ProjectInfo { + info := ProjectInfo{} + pjPath := filepath.Join(dir, "package.json") + goModPath := filepath.Join(dir, "go.mod") + cargoPath := filepath.Join(dir, "Cargo.toml") + reqsPath := filepath.Join(dir, "requirements.txt") + pyprojectPath := filepath.Join(dir, "pyproject.toml") + gemfilePath := filepath.Join(dir, "Gemfile") + composerPath := filepath.Join(dir, "composer.json") + + if fileExists(pjPath) { + info = detectNodeProject(dir) + } else if fileExists(goModPath) { + info = detectGoProject(dir) + } else if fileExists(cargoPath) { + info = detectRustProject(dir) + } else if fileExists(reqsPath) || fileExists(pyprojectPath) { + info = detectPythonProject(dir) + } else if fileExists(gemfilePath) { + info = detectRubyProject(dir) + } else if fileExists(composerPath) { + info = detectPHPProject(dir) + } + + if info.Name == "" { + info.Name = filepath.Base(dir) + } + + info.KeyDirectories = detectKeyDirectories(dir) + return info +} From 1ea66a3aa79a525467c25081d420f6c466959eae Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 11:44:54 -0500 Subject: [PATCH 3/9] feat: implement template processing with placeholder cleanup Co-Authored-By: Claude Opus 4.6 --- detect.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/detect.go b/detect.go index 7d4335e..2708b6c 100644 --- a/detect.go +++ b/detect.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "regexp" @@ -352,3 +353,70 @@ func detectProject(dir string) ProjectInfo { info.KeyDirectories = detectKeyDirectories(dir) return info } + +func applyProjectDetection(baseContent []byte, info ProjectInfo, embeddedFS fs.FS) []byte { + config := string(baseContent) + + config = strings.ReplaceAll(config, "{{PROJECT_NAME}}", info.Name) + config = strings.ReplaceAll(config, "{{PROJECT_DESCRIPTION}}", info.Description) + config = strings.ReplaceAll(config, "{{KEY_DIRECTORIES}}", formatKeyDirectories(info.KeyDirectories)) + config = strings.ReplaceAll(config, "{{TEST_COMMAND}}", info.TestCommand) + config = strings.ReplaceAll(config, "{{TYPECHECK_COMMAND}}", info.TypecheckCommand) + config = strings.ReplaceAll(config, "{{BUILD_COMMAND}}", info.BuildCommand) + config = strings.ReplaceAll(config, "{{FRAMEWORK}}", info.Framework) + + // Insert language-specific template at marker + if info.LanguageTemplate != "" { + if langContent, err := fs.ReadFile(embeddedFS, "templates/languages/"+info.LanguageTemplate); err == nil { + config = strings.ReplaceAll(config, "", string(langContent)) + } + } + config = strings.ReplaceAll(config, "", "") + + // Strip remaining unfilled placeholders + config = placeholderRe.ReplaceAllString(config, "") + + // Remove empty sections and collapse blank lines + config = removeEmptySections(config) + + return []byte(config) +} + +func removeEmptySections(s string) string { + lines := strings.Split(s, "\n") + var result []string + i := 0 + for i < len(lines) { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "## ") { + // Look ahead: is the section empty? + j := i + 1 + hasContent := false + for j < len(lines) { + nextTrimmed := strings.TrimSpace(lines[j]) + if strings.HasPrefix(nextTrimmed, "## ") || nextTrimmed == "---" { + break + } + if nextTrimmed != "" && nextTrimmed != "```" && nextTrimmed != "```bash" { + hasContent = true + break + } + j++ + } + if !hasContent { + i = j + continue + } + } + result = append(result, lines[i]) + i++ + } + return collapseBlankLines(strings.Join(result, "\n")) +} + +func collapseBlankLines(s string) string { + for strings.Contains(s, "\n\n\n") { + s = strings.ReplaceAll(s, "\n\n\n", "\n\n") + } + return s +} From 2c3d0521dd298bea424ae50a7382298ce1a730dc Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 12:11:32 -0500 Subject: [PATCH 4/9] feat: integrate project detection into config file generation Co-Authored-By: Claude Opus 4.6 --- main.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/main.go b/main.go index 8982c1c..ce61db8 100644 --- a/main.go +++ b/main.go @@ -350,14 +350,23 @@ func generateConfigFile(_ *installer.Installer, target Target) error { return fmt.Errorf("reading template: %w", err) } - // For Claude Code, install the full CLAUDE-BASE.md template - // For other frameworks, generate a simplified config + // Detect project type + cwd, _ := os.Getwd() + info := detectProject(cwd) + fmt.Printf("Detected: %s", info.Framework) + if info.Name != "" { + fmt.Printf(" (%s)", info.Name) + } + fmt.Println() + + // For Claude Code, apply project detection directly + // For other frameworks, generate a framework-specific config var configContent []byte switch target.Name { case "Claude Code": - configContent = baseContent + configContent = applyProjectDetection(baseContent, info, content) default: - configContent = generateFrameworkConfig(target, baseContent) + configContent = generateFrameworkConfig(target, baseContent, info) } if fileExists(configPath) { @@ -374,22 +383,16 @@ func generateConfigFile(_ *installer.Installer, target Target) error { return nil } -func generateFrameworkConfig(target Target, baseContent []byte) []byte { +func generateFrameworkConfig(target Target, baseContent []byte, info ProjectInfo) []byte { header := fmt.Sprintf("# %s - AI Agent Configuration\n\n", target.Name) header += fmt.Sprintf("Skills are installed in `%s/`\n", target.SkillsPath) if target.AgentsPath != "" { header += fmt.Sprintf("Agents are installed in `%s/`\n", target.AgentsPath) } - config := string(baseContent) - config = strings.ReplaceAll(config, "{{PROJECT_NAME}}", "Project") - config = strings.ReplaceAll(config, "{{PROJECT_DESCRIPTION}}", "") - config = strings.ReplaceAll(config, "{{KEY_DIRECTORIES}}", "") - config = strings.ReplaceAll(config, "{{TEST_COMMAND}}", "npm test") - config = strings.ReplaceAll(config, "{{TYPECHECK_COMMAND}}", "npm run typecheck") - config = strings.ReplaceAll(config, "{{BUILD_COMMAND}}", "npm run build") + processed := applyProjectDetection(baseContent, info, content) - return []byte(header + "\n---\n\n" + config) + return []byte(header + "\n---\n\n" + string(processed)) } func loadConfig() (*config.Config, error) { From c4522888c92650dab121b94aa7071150a6a66de1 Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 12:12:00 -0500 Subject: [PATCH 5/9] test: add project detection and template processing tests Co-Authored-By: Claude Opus 4.6 --- detect_test.go | 404 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 detect_test.go diff --git a/detect_test.go b/detect_test.go new file mode 100644 index 0000000..fc2cbfd --- /dev/null +++ b/detect_test.go @@ -0,0 +1,404 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func TestDetectGoProject(t *testing.T) { + t.Parallel() + dir := t.TempDir() + goMod := `module github.com/user/myapp + +go 1.21 +` + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644); err != nil { + t.Fatal(err) + } + + info := detectGoProject(dir) + + if info.Name != "myapp" { + t.Errorf("Name = %q, want %q", info.Name, "myapp") + } + if info.Framework != "Go" { + t.Errorf("Framework = %q, want %q", info.Framework, "Go") + } + if info.TestCommand != "go test ./..." { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "go test ./...") + } + if info.LanguageTemplate != "go.md" { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "go.md") + } + if info.BuildCommand != "go build ./..." { + t.Errorf("BuildCommand = %q, want %q", info.BuildCommand, "go build ./...") + } +} + +func TestDetectNodeProject_React(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pkg := `{ + "name": "my-react-app", + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatal(err) + } + + info := detectNodeProject(dir) + + if info.Framework != "React" { + t.Errorf("Framework = %q, want %q", info.Framework, "React") + } + if info.LanguageTemplate != "react.md" { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "react.md") + } +} + +func TestDetectNodeProject_NextJS(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pkg := `{ + "name": "my-next-app", + "dependencies": { + "next": "^14.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatal(err) + } + + info := detectNodeProject(dir) + + if info.Framework != "Next.js" { + t.Errorf("Framework = %q, want %q (should prefer Next.js over React)", info.Framework, "Next.js") + } + if info.LanguageTemplate != "react.md" { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "react.md") + } +} + +func TestDetectNodeProject_AdonisJS(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pkg := `{ + "name": "my-adonis-app", + "dependencies": { + "@adonisjs/core": "^6.0.0" + } +}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatal(err) + } + + info := detectNodeProject(dir) + + if info.Framework != "AdonisJS" { + t.Errorf("Framework = %q, want %q", info.Framework, "AdonisJS") + } + if info.LanguageTemplate != "adonisjs.md" { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "adonisjs.md") + } +} + +func TestDetectNodeProject_Express(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pkg := `{ + "name": "my-express-app", + "dependencies": { + "express": "^4.18.0" + } +}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatal(err) + } + + info := detectNodeProject(dir) + + if info.Framework != "Express" { + t.Errorf("Framework = %q, want %q", info.Framework, "Express") + } + if info.LanguageTemplate != "nodejs.md" { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "nodejs.md") + } +} + +func TestDetectNodeProject_ScriptOverride(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pkg := `{ + "name": "my-app", + "dependencies": { + "react": "^18.0.0" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest", + "build": "vite build" + } +}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatal(err) + } + + info := detectNodeProject(dir) + + if info.TypecheckCommand != "npm run typecheck" { + t.Errorf("TypecheckCommand = %q, want %q", info.TypecheckCommand, "npm run typecheck") + } + if info.TestCommand != "npm test" { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "npm test") + } + if info.BuildCommand != "npm run build" { + t.Errorf("BuildCommand = %q, want %q", info.BuildCommand, "npm run build") + } +} + +func TestDetectRustProject(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cargo := `[package] +name = "my-rust-tool" +description = "A blazingly fast CLI tool" +version = "0.1.0" +edition = "2021" + +[dependencies] +` + if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte(cargo), 0644); err != nil { + t.Fatal(err) + } + + info := detectRustProject(dir) + + if info.Name != "my-rust-tool" { + t.Errorf("Name = %q, want %q", info.Name, "my-rust-tool") + } + if info.Description != "A blazingly fast CLI tool" { + t.Errorf("Description = %q, want %q", info.Description, "A blazingly fast CLI tool") + } + if info.Framework != "Rust" { + t.Errorf("Framework = %q, want %q", info.Framework, "Rust") + } + if info.TestCommand != "cargo test" { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "cargo test") + } +} + +func TestDetectPythonProject(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pyproject := `[project] +name = "my-python-lib" +version = "1.0.0" +description = "A Python library" + +[build-system] +requires = ["setuptools"] +` + if err := os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(pyproject), 0644); err != nil { + t.Fatal(err) + } + + info := detectPythonProject(dir) + + if info.Name != "my-python-lib" { + t.Errorf("Name = %q, want %q", info.Name, "my-python-lib") + } + if info.TestCommand != "pytest" { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "pytest") + } + if info.Framework != "Python" { + t.Errorf("Framework = %q, want %q", info.Framework, "Python") + } +} + +func TestDetectKeyDirectories(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create subdirectories + for _, name := range []string{"src", "tests", "docs"} { + if err := os.Mkdir(filepath.Join(dir, name), 0755); err != nil { + t.Fatal(err) + } + } + + found := detectKeyDirectories(dir) + + expected := map[string]bool{ + "src/": false, + "tests/": false, + "docs/": false, + } + for _, d := range found { + if _, ok := expected[d]; ok { + expected[d] = true + } + } + for d, seen := range expected { + if !seen { + t.Errorf("expected directory %q not found in result %v", d, found) + } + } +} + +func TestDetectProject_Fallback(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + info := detectProject(dir) + + // Name should fall back to the directory basename + basename := filepath.Base(dir) + if info.Name != basename { + t.Errorf("Name = %q, want %q (dir basename)", info.Name, basename) + } + if info.Framework != "" { + t.Errorf("Framework = %q, want empty string", info.Framework) + } +} + +func TestRemoveUnfilledPlaceholders(t *testing.T) { + t.Parallel() + + input := "Hello {{FOO}} world {{BAR_BAZ}}" + result := placeholderRe.ReplaceAllString(input, "") + + if strings.Contains(result, "{{FOO}}") { + t.Errorf("result still contains {{FOO}}: %q", result) + } + if strings.Contains(result, "{{BAR_BAZ}}") { + t.Errorf("result still contains {{BAR_BAZ}}: %q", result) + } + expected := "Hello world " + if result != expected { + t.Errorf("result = %q, want %q", result, expected) + } +} + +func TestRemoveEmptySections(t *testing.T) { + t.Parallel() + + input := "## Project Overview\n\n---" + result := removeEmptySections(input) + + if strings.Contains(result, "## Project Overview") { + t.Errorf("empty section was not removed: %q", result) + } +} + +func TestRemoveEmptySections_EmptyCodeFence(t *testing.T) { + t.Parallel() + + input := "## Quick Reference\n\n```bash\n```\n\n---" + result := removeEmptySections(input) + + if strings.Contains(result, "## Quick Reference") { + t.Errorf("section with empty code fence was not removed: %q", result) + } +} + +func TestApplyProjectDetection(t *testing.T) { + t.Parallel() + + baseTemplate := `# {{PROJECT_NAME}} - Claude Code Configuration + +## Project Overview + +{{PROJECT_DESCRIPTION}} + +## Key Directories + +{{KEY_DIRECTORIES}} + +--- + +## Quick Reference + +` + "```bash" + ` +{{TEST_COMMAND}} +{{TYPECHECK_COMMAND}} +` + "```" + ` + +--- + + +` + + goLangTemplate := `## Go Rules + +- Follow gofmt conventions +- Use table-driven tests +` + + mockFS := fstest.MapFS{ + "templates/languages/go.md": &fstest.MapFile{ + Data: []byte(goLangTemplate), + }, + } + + info := ProjectInfo{ + Name: "myapp", + Description: "A great application", + Framework: "Go", + LanguageTemplate: "go.md", + TestCommand: "go test ./...", + TypecheckCommand: "go vet ./...", + BuildCommand: "go build ./...", + KeyDirectories: []string{"src/", "tests/"}, + } + + result := string(applyProjectDetection([]byte(baseTemplate), info, mockFS)) + + // All named placeholders should be replaced + if strings.Contains(result, "{{") { + t.Errorf("result still contains unfilled placeholders: %q", result) + } + + // Project name should appear in the title + if !strings.Contains(result, "# myapp - Claude Code Configuration") { + t.Errorf("project name not replaced in title") + } + + // Description should be present + if !strings.Contains(result, "A great application") { + t.Errorf("description not replaced") + } + + // Key directories should be formatted + if !strings.Contains(result, "- `src/`") { + t.Errorf("key directory src/ not found") + } + if !strings.Contains(result, "- `tests/`") { + t.Errorf("key directory tests/ not found") + } + + // Test command should appear + if !strings.Contains(result, "go test ./...") { + t.Errorf("test command not found in result") + } + + // Language template should be inserted + if !strings.Contains(result, "## Go Rules") { + t.Errorf("language template not inserted") + } + if !strings.Contains(result, "table-driven tests") { + t.Errorf("language template content not found") + } + + // LANGUAGE_SPECIFIC marker should be gone + if strings.Contains(result, "") { + t.Errorf("LANGUAGE_SPECIFIC marker still present") + } +} From f6e71f46c96358511e4794b85ed7a26a748d0ca2 Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 13:56:04 -0500 Subject: [PATCH 6/9] fix: show detection in dry-run and clean up empty command artifacts Co-Authored-By: Claude Opus 4.6 --- detect.go | 7 +++++++ detect_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ main.go | 18 +++++++++--------- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/detect.go b/detect.go index 2708b6c..061ebf8 100644 --- a/detect.go +++ b/detect.go @@ -376,6 +376,13 @@ func applyProjectDetection(baseContent []byte, info ProjectInfo, embeddedFS fs.F // Strip remaining unfilled placeholders config = placeholderRe.ReplaceAllString(config, "") + // Clean up empty command artifacts in tables (e.g., "` + ` `" when typecheck is empty) + config = regexp.MustCompile("`\\s*\\+\\s*`\\s*`").ReplaceAllString(config, "`") + + // Remove blank lines inside code fences + config = regexp.MustCompile("(?m)^(```bash\n)\\s*\n").ReplaceAllString(config, "$1") + config = regexp.MustCompile("(?m)\n\\s*\n(```)").ReplaceAllString(config, "\n$1") + // Remove empty sections and collapse blank lines config = removeEmptySections(config) diff --git a/detect_test.go b/detect_test.go index fc2cbfd..8455e76 100644 --- a/detect_test.go +++ b/detect_test.go @@ -309,6 +309,48 @@ func TestRemoveEmptySections_EmptyCodeFence(t *testing.T) { } } +func TestApplyProjectDetection_EmptyTypecheck(t *testing.T) { + t.Parallel() + + // Template mimics the real CLAUDE-BASE.md layout with table and code fence + baseTemplate := "# {{PROJECT_NAME}}\n\n" + + "| 5. TEST | `{{TEST_COMMAND}}` + `{{TYPECHECK_COMMAND}}` | — | Zero failures |\n\n" + + "## Quick Reference\n\n" + + "```bash\n{{TEST_COMMAND}}\n{{TYPECHECK_COMMAND}}\n```\n\n---\n" + + mockFS := fstest.MapFS{} + + info := ProjectInfo{ + Name: "mygoapp", + Framework: "Go", + TestCommand: "go test ./...", + // TypecheckCommand intentionally empty + } + + result := string(applyProjectDetection([]byte(baseTemplate), info, mockFS)) + + // Table row should NOT contain empty backtick artifact ("` + ` `") + if strings.Contains(result, "+ ` `") { + t.Errorf("result still contains empty backtick artifact: %q", result) + } + // Table should still have test command in backticks + if !strings.Contains(result, "`go test ./...`") { + t.Errorf("test command backticks missing from table") + } + + // Quick Reference code fence should NOT have blank lines + if strings.Contains(result, "```bash\n\n") { + t.Errorf("code fence has leading blank line") + } + if strings.Contains(result, "\n\n```") { + t.Errorf("code fence has trailing blank line") + } + // Test command should be present in the code fence + if !strings.Contains(result, "```bash\ngo test ./...") { + t.Errorf("test command not found in code fence: %q", result) + } +} + func TestApplyProjectDetection(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index ce61db8..87d9623 100644 --- a/main.go +++ b/main.go @@ -330,6 +330,15 @@ func runFullInstall(reader *bufio.Reader, inst *installer.Installer, target Targ func generateConfigFile(_ *installer.Installer, target Target) error { configPath := filepath.Join(".", target.ConfigPath) + // Detect project type (safe in dry-run: only reads the filesystem) + cwd, _ := os.Getwd() + info := detectProject(cwd) + fmt.Printf("Detected: %s", info.Framework) + if info.Name != "" { + fmt.Printf(" (%s)", info.Name) + } + fmt.Println() + if dryRun { if fileExists(configPath) { fmt.Printf("WOULD UPDATE: %s\n", configPath) @@ -350,15 +359,6 @@ func generateConfigFile(_ *installer.Installer, target Target) error { return fmt.Errorf("reading template: %w", err) } - // Detect project type - cwd, _ := os.Getwd() - info := detectProject(cwd) - fmt.Printf("Detected: %s", info.Framework) - if info.Name != "" { - fmt.Printf(" (%s)", info.Name) - } - fmt.Println() - // For Claude Code, apply project detection directly // For other frameworks, generate a framework-specific config var configContent []byte From a6b83fd8e77160f05e4cc4273499aad14775f611 Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 15:25:44 -0500 Subject: [PATCH 7/9] fix: address code review findings (error handling, UX, regex hoisting) Co-Authored-By: Claude Opus 4.6 --- detect.go | 32 ++++++++++++++++++++++++-------- main.go | 13 ++++++++----- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/detect.go b/detect.go index 061ebf8..f4713ec 100644 --- a/detect.go +++ b/detect.go @@ -51,14 +51,18 @@ func hasKeyPrefix(m map[string]string, prefix string) bool { return false } -// extractTOMLString extracts a string value from a simple TOML `key = "value"` line. -// Only handles double-quoted values on a single line. Sufficient for name/description fields. +// extractTOMLString extracts a string value from a simple TOML `key = "value"` or `key = 'value'` line. +// Handles both double-quoted and single-quoted (literal) TOML strings on a single line. +// Sufficient for name/description fields. func extractTOMLString(line string) string { parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { return "" } - return strings.Trim(strings.TrimSpace(parts[1]), "\"") + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, "\"") + val = strings.Trim(val, "'") + return val } func formatKeyDirectories(dirs []string) string { @@ -72,8 +76,13 @@ func formatKeyDirectories(dirs []string) string { return strings.Join(lines, "\n") } -// placeholderRe matches {{PLACEHOLDER}} patterns in templates. -var placeholderRe = regexp.MustCompile(`\{\{[A-Z_]+\}\}`) +// Package-level compiled regexps used in applyProjectDetection. +var ( + placeholderRe = regexp.MustCompile(`\{\{[A-Z_]+\}\}`) + emptyBacktickArtifact = regexp.MustCompile("`\\s*\\+\\s*`\\s*`") + codeFenceLeadingBlank = regexp.MustCompile("(?m)^(```bash\n)\\s*\n") + codeFenceTrailingBlank = regexp.MustCompile("(?m)\n\\s*\n(```)") +) func detectKeyDirectories(dir string) []string { candidates := []string{ @@ -150,6 +159,10 @@ func detectRustProject(dir string) ProjectInfo { return info } +// detectPythonProject detects Python projects from pyproject.toml or requirements.txt. +// For requirements.txt-only projects (no pyproject.toml), the defaults are returned as-is +// (Framework="Python", TestCommand="pytest") since requirements.txt has no name/description +// fields to extract. func detectPythonProject(dir string) ProjectInfo { info := ProjectInfo{ Framework: "Python", @@ -297,6 +310,9 @@ func detectNodeProject(dir string) ProjectInfo { info.BuildCommand = "npm run build" } + // Script overrides may replace framework-specific commands (e.g., AdonisJS "node ace test" + // becomes "npm test") which is correct since "npm test" invokes whatever script is defined + // in package.json, including the framework-specific command. return overrideFromScripts(info, pkg.Scripts) } @@ -377,11 +393,11 @@ func applyProjectDetection(baseContent []byte, info ProjectInfo, embeddedFS fs.F config = placeholderRe.ReplaceAllString(config, "") // Clean up empty command artifacts in tables (e.g., "` + ` `" when typecheck is empty) - config = regexp.MustCompile("`\\s*\\+\\s*`\\s*`").ReplaceAllString(config, "`") + config = emptyBacktickArtifact.ReplaceAllString(config, "`") // Remove blank lines inside code fences - config = regexp.MustCompile("(?m)^(```bash\n)\\s*\n").ReplaceAllString(config, "$1") - config = regexp.MustCompile("(?m)\n\\s*\n(```)").ReplaceAllString(config, "\n$1") + config = codeFenceLeadingBlank.ReplaceAllString(config, "$1") + config = codeFenceTrailingBlank.ReplaceAllString(config, "\n$1") // Remove empty sections and collapse blank lines config = removeEmptySections(config) diff --git a/main.go b/main.go index 87d9623..acf6d06 100644 --- a/main.go +++ b/main.go @@ -331,13 +331,16 @@ func generateConfigFile(_ *installer.Installer, target Target) error { configPath := filepath.Join(".", target.ConfigPath) // Detect project type (safe in dry-run: only reads the filesystem) - cwd, _ := os.Getwd() + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("cannot determine working directory: %w", err) + } info := detectProject(cwd) - fmt.Printf("Detected: %s", info.Framework) - if info.Name != "" { - fmt.Printf(" (%s)", info.Name) + if info.Framework != "" { + fmt.Printf("Detected: %s (%s)\n", info.Framework, info.Name) + } else { + fmt.Printf("Detected: unknown project type (using defaults for %s)\n", info.Name) } - fmt.Println() if dryRun { if fileExists(configPath) { From 4f6561baa1aa425eedfb2eb4a9a1f74f39255218 Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 15:29:56 -0500 Subject: [PATCH 8/9] refactor: apply code simplification recommendations - reduce repetition in detectNodeProject with shared defaults - fix extractTOMLString double-Trim bug with apostrophes - rename shadowed info variable in detectKeyDirectories - extract writeTestFile helper in tests - consolidate Node framework tests into table-driven test - remove redundant TestCommand in Rails branch Co-Authored-By: Claude Opus 4.6 --- detect.go | 42 ++++++-------- detect_test.go | 149 ++++++++++++++++++++++--------------------------- 2 files changed, 84 insertions(+), 107 deletions(-) diff --git a/detect.go b/detect.go index f4713ec..47c659a 100644 --- a/detect.go +++ b/detect.go @@ -60,8 +60,11 @@ func extractTOMLString(line string) string { return "" } val := strings.TrimSpace(parts[1]) - val = strings.Trim(val, "\"") - val = strings.Trim(val, "'") + if strings.HasPrefix(val, "\"") { + val = strings.Trim(val, "\"") + } else if strings.HasPrefix(val, "'") { + val = strings.Trim(val, "'") + } return val } @@ -94,8 +97,8 @@ func detectKeyDirectories(dir string) []string { } var found []string for _, name := range candidates { - info, err := os.Stat(filepath.Join(dir, name)) - if err == nil && info.IsDir() { + fi, err := os.Stat(filepath.Join(dir, name)) + if err == nil && fi.IsDir() { found = append(found, name+"/") } } @@ -218,7 +221,6 @@ func detectRubyProject(dir string) ProjectInfo { content := string(data) if strings.Contains(content, "'rails'") || strings.Contains(content, "\"rails\"") { info.Framework = "Rails" - info.TestCommand = "bundle exec rspec" info.BuildCommand = "bundle exec rails assets:precompile" } return info @@ -264,14 +266,16 @@ func detectNodeProject(dir string) ProjectInfo { } info := ProjectInfo{ - Name: pkg.Name, - Description: pkg.Description, + Name: pkg.Name, + Description: pkg.Description, + TestCommand: "npm test", + TypecheckCommand: "npx tsc --noEmit", + BuildCommand: "npm run build", } allDeps := mergeMaps(pkg.Dependencies, pkg.DevDependencies) - // Priority order: most specific first. Next.js MUST come before React - // because Next.js projects always have react as a dependency too. + // Priority order: most specific first if hasKeyPrefix(allDeps, "@adonisjs/") { info.Framework = "AdonisJS" info.LanguageTemplate = "adonisjs.md" @@ -281,38 +285,24 @@ func detectNodeProject(dir string) ProjectInfo { } else if _, ok := allDeps["svelte"]; ok { info.Framework = "Svelte" info.LanguageTemplate = "svelte.md" - info.TestCommand = "npm test" info.TypecheckCommand = "npx svelte-check" - info.BuildCommand = "npm run build" } else if _, ok := allDeps["next"]; ok { info.Framework = "Next.js" info.LanguageTemplate = "react.md" - info.TestCommand = "npm test" - info.TypecheckCommand = "npx tsc --noEmit" - info.BuildCommand = "npm run build" } else if _, ok := allDeps["react"]; ok { info.Framework = "React" info.LanguageTemplate = "react.md" - info.TestCommand = "npm test" - info.TypecheckCommand = "npx tsc --noEmit" - info.BuildCommand = "npm run build" } else if _, ok := allDeps["express"]; ok { info.Framework = "Express" info.LanguageTemplate = "nodejs.md" - info.TestCommand = "npm test" - info.TypecheckCommand = "npx tsc --noEmit" - info.BuildCommand = "npm run build" } else { info.Framework = "Node.js" info.LanguageTemplate = "nodejs.md" - info.TestCommand = "npm test" - info.TypecheckCommand = "npx tsc --noEmit" - info.BuildCommand = "npm run build" } - // Script overrides may replace framework-specific commands (e.g., AdonisJS "node ace test" - // becomes "npm test") which is correct since "npm test" invokes whatever script is defined - // in package.json, including the framework-specific command. + // Script overrides may replace framework-specific commands (e.g., AdonisJS + // "node ace test" becomes "npm test"), which is correct since npm test invokes + // whatever script is defined in package.json. return overrideFromScripts(info, pkg.Scripts) } diff --git a/detect_test.go b/detect_test.go index 8455e76..998dfeb 100644 --- a/detect_test.go +++ b/detect_test.go @@ -8,6 +8,13 @@ import ( "testing/fstest" ) +func writeTestFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + func TestDetectGoProject(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -15,9 +22,7 @@ func TestDetectGoProject(t *testing.T) { go 1.21 ` - if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644); err != nil { - t.Fatal(err) - } + writeTestFile(t, dir, "go.mod", goMod) info := detectGoProject(dir) @@ -38,98 +43,86 @@ go 1.21 } } -func TestDetectNodeProject_React(t *testing.T) { +func TestDetectNodeProject_Frameworks(t *testing.T) { t.Parallel() - dir := t.TempDir() - pkg := `{ + tests := []struct { + name string + packageJSON string + wantFramework string + wantLangTemplate string + }{ + { + name: "React", + packageJSON: `{ "name": "my-react-app", "dependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } -}` - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { - t.Fatal(err) - } - - info := detectNodeProject(dir) - - if info.Framework != "React" { - t.Errorf("Framework = %q, want %q", info.Framework, "React") - } - if info.LanguageTemplate != "react.md" { - t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "react.md") - } -} - -func TestDetectNodeProject_NextJS(t *testing.T) { - t.Parallel() - dir := t.TempDir() - pkg := `{ +}`, + wantFramework: "React", + wantLangTemplate: "react.md", + }, + { + name: "NextJS", + packageJSON: `{ "name": "my-next-app", "dependencies": { "next": "^14.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" } -}` - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { - t.Fatal(err) - } - - info := detectNodeProject(dir) - - if info.Framework != "Next.js" { - t.Errorf("Framework = %q, want %q (should prefer Next.js over React)", info.Framework, "Next.js") - } - if info.LanguageTemplate != "react.md" { - t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "react.md") - } -} - -func TestDetectNodeProject_AdonisJS(t *testing.T) { - t.Parallel() - dir := t.TempDir() - pkg := `{ +}`, + wantFramework: "Next.js", + wantLangTemplate: "react.md", + }, + { + name: "AdonisJS", + packageJSON: `{ "name": "my-adonis-app", "dependencies": { "@adonisjs/core": "^6.0.0" } -}` - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { - t.Fatal(err) - } - - info := detectNodeProject(dir) - - if info.Framework != "AdonisJS" { - t.Errorf("Framework = %q, want %q", info.Framework, "AdonisJS") - } - if info.LanguageTemplate != "adonisjs.md" { - t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "adonisjs.md") - } -} - -func TestDetectNodeProject_Express(t *testing.T) { - t.Parallel() - dir := t.TempDir() - pkg := `{ +}`, + wantFramework: "AdonisJS", + wantLangTemplate: "adonisjs.md", + }, + { + name: "Express", + packageJSON: `{ "name": "my-express-app", "dependencies": { "express": "^4.18.0" } -}` - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { - t.Fatal(err) +}`, + wantFramework: "Express", + wantLangTemplate: "nodejs.md", + }, + { + name: "Node.js fallback", + packageJSON: `{ + "name": "my-plain-app", + "description": "A plain Node.js app" +}`, + wantFramework: "Node.js", + wantLangTemplate: "nodejs.md", + }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTestFile(t, dir, "package.json", tt.packageJSON) - info := detectNodeProject(dir) + info := detectNodeProject(dir) - if info.Framework != "Express" { - t.Errorf("Framework = %q, want %q", info.Framework, "Express") - } - if info.LanguageTemplate != "nodejs.md" { - t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "nodejs.md") + if info.Framework != tt.wantFramework { + t.Errorf("Framework = %q, want %q", info.Framework, tt.wantFramework) + } + if info.LanguageTemplate != tt.wantLangTemplate { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, tt.wantLangTemplate) + } + }) } } @@ -147,9 +140,7 @@ func TestDetectNodeProject_ScriptOverride(t *testing.T) { "build": "vite build" } }` - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { - t.Fatal(err) - } + writeTestFile(t, dir, "package.json", pkg) info := detectNodeProject(dir) @@ -175,9 +166,7 @@ edition = "2021" [dependencies] ` - if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte(cargo), 0644); err != nil { - t.Fatal(err) - } + writeTestFile(t, dir, "Cargo.toml", cargo) info := detectRustProject(dir) @@ -206,9 +195,7 @@ description = "A Python library" [build-system] requires = ["setuptools"] ` - if err := os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(pyproject), 0644); err != nil { - t.Fatal(err) - } + writeTestFile(t, dir, "pyproject.toml", pyproject) info := detectPythonProject(dir) From 36fc18cc5e484cd6bf3fd5ab764e38217e61348c Mon Sep 17 00:00:00 2001 From: Gerald Onyango Date: Fri, 13 Feb 2026 15:32:10 -0500 Subject: [PATCH 9/9] fix: capture loop variable for Go 1.21 compatibility Co-Authored-By: Claude Opus 4.6 --- detect_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/detect_test.go b/detect_test.go index 998dfeb..49f71dd 100644 --- a/detect_test.go +++ b/detect_test.go @@ -109,6 +109,7 @@ func TestDetectNodeProject_Frameworks(t *testing.T) { }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() dir := t.TempDir()