diff --git a/.gitignore b/.gitignore index 492e6d68..d432e6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ apps/openant-cli/bin/ libs/openant-core/parsers/go/go_parser/go_parser libs/openant-core/parsers/javascript/.openant-npm-install.lock _docs/ - +docs/ +.worktrees/ diff --git a/apps/openant-cli/cmd/init.go b/apps/openant-cli/cmd/init.go index 50934aa9..01c4a60a 100644 --- a/apps/openant-cli/cmd/init.go +++ b/apps/openant-cli/cmd/init.go @@ -1,7 +1,9 @@ package cmd import ( + "encoding/json" "fmt" + "io/fs" "os" "os/exec" "path/filepath" @@ -26,6 +28,7 @@ After init, all commands (parse, scan, etc.) work without path arguments. Examples: openant init https://github.com/grafana/grafana -l go openant init https://github.com/grafana/grafana -l go --commit 591ceb2eec0 + openant init https://github.com/grafana/grafana -l auto openant init ./repos/grafana -l go openant init ./repos/grafana -l go --name myorg/grafana`, Args: cobra.ExactArgs(1), @@ -44,7 +47,7 @@ var ( ) func init() { - initCmd.Flags().StringVarP(&initLanguage, "language", "l", "", "Language to analyze: python, javascript, go, c, ruby, php (required)") + initCmd.Flags().StringVarP(&initLanguage, "language", "l", "", "Language to analyze: python, javascript, go, c, ruby, php, zig, auto (auto = experimental dominance heuristic; see #61)") initCmd.Flags().StringVar(&initCommit, "commit", "", "Specific commit SHA (default: HEAD)") initCmd.Flags().StringVar(&initName, "name", "", "Override project name (default: derived from URL/path)") initCmd.Flags().BoolVar(&initFull, "full", false, "Force full scan (rejects --incremental/--diff-base/--pr)") @@ -118,7 +121,7 @@ func runInit(cmd *cobra.Command, args []string) { } } } else { - // Local: verify it's a git repo and resolve absolute path + // Local: resolve absolute path source = "local" absPath, err := filepath.Abs(input) @@ -127,29 +130,48 @@ func runInit(cmd *cobra.Command, args []string) { os.Exit(1) } - if _, err := os.Stat(filepath.Join(absPath, ".git")); err != nil { - output.PrintError(fmt.Sprintf("%s is not a git repository (no .git directory)", absPath)) + repoPath = absPath + } + + // Auto-detect language if not specified + if initLanguage == "" || initLanguage == "auto" { + fmt.Fprintf(os.Stderr, "Auto-detecting language...\n") + detected, err := detectLanguage(repoPath) + if err != nil { + output.PrintError(fmt.Sprintf("Language auto-detection failed: %s\nSpecify manually with -l/--language", err)) os.Exit(1) } + initLanguage = detected + fmt.Fprintf(os.Stderr, "Detected language: %s\n", initLanguage) + } - repoPath = absPath + // Get commit SHA (best-effort — not all local paths are git repos) + isGit := false + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + isGit = true } - // Get commit SHA commitSHA := initCommit - if commitSHA == "" { - out, err := exec.Command("git", "-C", repoPath, "rev-parse", "HEAD").Output() - if err != nil { - output.PrintError(fmt.Sprintf("Failed to get HEAD commit: %s", err)) - os.Exit(1) + if isGit { + if commitSHA == "" { + out, err := exec.Command("git", "-C", repoPath, "rev-parse", "HEAD").Output() + if err != nil { + output.PrintError(fmt.Sprintf("Failed to get HEAD commit: %s", err)) + os.Exit(1) + } + commitSHA = strings.TrimSpace(string(out)) + } else { + // Resolve short SHA to full SHA + out, err := exec.Command("git", "-C", repoPath, "rev-parse", commitSHA).Output() + if err == nil { + commitSHA = strings.TrimSpace(string(out)) + } } - commitSHA = strings.TrimSpace(string(out)) } else { - // Resolve short SHA to full SHA - out, err := exec.Command("git", "-C", repoPath, "rev-parse", commitSHA).Output() - if err == nil { - commitSHA = strings.TrimSpace(string(out)) + if commitSHA != "" { + output.PrintWarning("--commit ignored: not a git repository") } + commitSHA = "nogit" } // Create project @@ -224,3 +246,125 @@ func runInit(cmd *cobra.Command, args []string) { output.PrintSuccess("Set as active project") fmt.Println() } + +// languagesConfig is the structure of config/languages.json. +type languagesConfig struct { + SkipDirs []string `json:"skip_dirs"` + Extensions map[string]string `json:"extensions"` +} + +// findLanguagesConfig locates config/languages.json by walking up from the +// executable path and then the current working directory. +func findLanguagesConfig() (string, error) { + rel := filepath.Join("config", "languages.json") + + // Strategy 1: walk up from the executable. + if exePath, err := os.Executable(); err == nil { + exePath, _ = filepath.EvalSymlinks(exePath) + dir := filepath.Dir(exePath) + for range 6 { + candidate := filepath.Join(dir, rel) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + + // Strategy 2: walk up from CWD. + if cwd, err := os.Getwd(); err == nil { + dir := cwd + for range 6 { + candidate := filepath.Join(dir, rel) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + + return "", fmt.Errorf("could not find config/languages.json from executable or working directory") +} + +// loadLanguagesConfig loads the shared language detection config. +func loadLanguagesConfig() (*languagesConfig, error) { + path, err := findLanguagesConfig() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + var cfg languagesConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + return &cfg, nil +} + +// detectLanguage walks a repository and returns the dominant language by file count. +// Extension mappings and skip directories are loaded from config/languages.json +// (shared with libs/openant-core/core/parser_adapter.py::detect_language()). +func detectLanguage(repoPath string) (string, error) { + cfg, err := loadLanguagesConfig() + if err != nil { + return "", fmt.Errorf("failed to load language config: %w", err) + } + + skipDirs := make(map[string]bool, len(cfg.SkipDirs)) + for _, d := range cfg.SkipDirs { + skipDirs[d] = true + } + + counts := make(map[string]int) + + err = filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip inaccessible paths + } + if d.IsDir() { + if skipDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + + ext := strings.ToLower(filepath.Ext(d.Name())) + if lang, ok := cfg.Extensions[ext]; ok { + counts[lang]++ + } + return nil + }) + if err != nil { + return "", fmt.Errorf("failed to walk repository: %w", err) + } + + // Find the dominant language + bestLang := "" + bestCount := 0 + for lang, count := range counts { + if count > bestCount { + bestCount = count + bestLang = lang + } + } + + if bestLang == "" { + return "", fmt.Errorf( + "no supported source files found in %s. "+ + "Supported languages: Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig", + repoPath, + ) + } + + return bestLang, nil +} diff --git a/config/languages.json b/config/languages.json new file mode 100644 index 00000000..7a99dded --- /dev/null +++ b/config/languages.json @@ -0,0 +1,34 @@ +{ + "skip_dirs": [ + "node_modules", + "__pycache__", + "venv", + ".venv", + "dist", + "build", + ".git", + "vendor" + ], + "extensions": { + ".py": "python", + ".js": "javascript", + ".ts": "javascript", + ".jsx": "javascript", + ".tsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".go": "go", + ".c": "c", + ".h": "c", + ".cpp": "c", + ".hpp": "c", + ".cc": "c", + ".cxx": "c", + ".hxx": "c", + ".hh": "c", + ".rb": "ruby", + ".rake": "ruby", + ".php": "php", + ".zig": "zig" + } +} diff --git a/libs/openant-core/core/parser_adapter.py b/libs/openant-core/core/parser_adapter.py index f2f81745..acaec8f5 100644 --- a/libs/openant-core/core/parser_adapter.py +++ b/libs/openant-core/core/parser_adapter.py @@ -26,46 +26,43 @@ # JS parser directory (holds its own package.json / node_modules) _JS_PARSER_DIR = _CORE_ROOT / "parsers" / "javascript" +# Shared language detection config (single source of truth: config/languages.json) +_LANGUAGES_CONFIG = Path(__file__).parent.parent.parent.parent / "config" / "languages.json" + + +def _load_language_config() -> dict: + return read_json(_LANGUAGES_CONFIG) + def detect_language(repo_path: str) -> str: """Auto-detect the primary language of a repository. Counts source files by extension and returns the dominant language. + Extension mappings and skip directories are loaded from config/languages.json. Returns: - "python", "javascript", or "go" + One of: "python", "javascript", "go", "c", "ruby", "php", "zig" """ + config = _load_language_config() + skip_dirs = set(config["skip_dirs"]) + extensions = config["extensions"] + repo = Path(repo_path) - counts = {"python": 0, "javascript": 0, "go": 0, "c": 0, "ruby": 0, "php": 0, "zig": 0} + counts: dict[str, int] = {} for f in repo.rglob("*"): if not f.is_file(): continue - # Skip common non-source dirs - parts = f.parts - if any(p in parts for p in ( - "node_modules", "__pycache__", "venv", ".venv", - "dist", "build", ".git", "vendor", - )): + # Skip configured non-source dirs + if any(p in skip_dirs for p in f.parts): continue suffix = f.suffix.lower() - if suffix == ".py": - counts["python"] += 1 - elif suffix in (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"): - counts["javascript"] += 1 - elif suffix == ".go": - counts["go"] += 1 - elif suffix in (".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", ".hxx", ".hh"): - counts["c"] += 1 - elif suffix in (".rb", ".rake"): - counts["ruby"] += 1 - elif suffix == ".php": - counts["php"] += 1 - elif suffix == ".zig": - counts["zig"] += 1 - - if not any(counts.values()): + if suffix in extensions: + lang = extensions[suffix] + counts[lang] = counts.get(lang, 0) + 1 + + if not counts: raise ValueError( f"No supported source files found in {repo_path}. " "Supported languages: Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig." diff --git a/libs/openant-core/tests/test_detect_language.py b/libs/openant-core/tests/test_detect_language.py new file mode 100644 index 00000000..9cfc827d --- /dev/null +++ b/libs/openant-core/tests/test_detect_language.py @@ -0,0 +1,131 @@ +"""Unit tests for ``detect_language`` in ``core.parser_adapter``. + +These tests build small synthetic project trees with ``tmp_path`` and assert +that the dominant-extension heuristic reports the correct language. They run +without the Go CLI binary, so they always execute in CI even when the Go +toolchain isn't installed. + +Covers item 13 of issue #16 (auto-detect language in ``init``). +""" +from pathlib import Path + +import pytest + +from core.parser_adapter import detect_language + + +def _write(p: Path, content: str = "") -> None: + """Create a file with ``content`` at ``p``, including parent dirs.""" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + + +class TestDetectLanguagePython: + def test_single_python_file(self, tmp_path: Path) -> None: + _write(tmp_path / "main.py", "print('hi')\n") + assert detect_language(str(tmp_path)) == "python" + + def test_dominant_python_with_unrelated_files(self, tmp_path: Path) -> None: + for i in range(5): + _write(tmp_path / f"mod_{i}.py") + _write(tmp_path / "README.md", "# project") + _write(tmp_path / "data.json", "{}") + assert detect_language(str(tmp_path)) == "python" + + +class TestDetectLanguageJavaScript: + def test_plain_javascript(self, tmp_path: Path) -> None: + _write(tmp_path / "index.js", "module.exports = {};\n") + _write(tmp_path / "lib.js") + assert detect_language(str(tmp_path)) == "javascript" + + def test_typescript_classified_as_javascript(self, tmp_path: Path) -> None: + # The shared config maps .ts/.tsx/.jsx/.mjs/.cjs to "javascript". + for name in ("app.ts", "comp.tsx", "old.jsx", "esm.mjs", "cjs.cjs"): + _write(tmp_path / name) + assert detect_language(str(tmp_path)) == "javascript" + + def test_typescript_dominant_over_python(self, tmp_path: Path) -> None: + for i in range(4): + _write(tmp_path / f"src_{i}.ts") + _write(tmp_path / "scripts" / "release.py") + assert detect_language(str(tmp_path)) == "javascript" + + +class TestDetectLanguageGo: + def test_single_go_file(self, tmp_path: Path) -> None: + _write(tmp_path / "main.go", "package main\n") + assert detect_language(str(tmp_path)) == "go" + + def test_go_dominant_over_other_extensions(self, tmp_path: Path) -> None: + for i in range(6): + _write(tmp_path / f"pkg_{i}.go") + _write(tmp_path / "tools" / "fix.py") + _write(tmp_path / "web" / "ui.js") + assert detect_language(str(tmp_path)) == "go" + + +class TestDetectLanguageMixed: + """Mixed-language repos must report the dominant language by file count. + + Unlike the per-language classes above which lean on skip_dirs to mask + competing extensions, these cases place real source from two languages + side-by-side at the root so the dominance heuristic itself is exercised. + """ + + def test_ts_dominant_over_python_at_root(self, tmp_path: Path) -> None: + # 6 TS source files vs 4 Python tooling files at the same level — + # mirrors a typical Node project that ships a few Python build + # scripts. No skip_dirs trickery involved. + for i in range(6): + _write(tmp_path / "src" / f"mod_{i}.ts") + for i in range(4): + _write(tmp_path / "scripts" / f"tool_{i}.py") + assert detect_language(str(tmp_path)) == "javascript" + + def test_python_dominant_over_javascript_at_root(self, tmp_path: Path) -> None: + # Inverse case: Python repo with a small JS frontend. + for i in range(7): + _write(tmp_path / f"pkg_{i}.py") + for i in range(3): + _write(tmp_path / "frontend" / f"page_{i}.js") + assert detect_language(str(tmp_path)) == "python" + + +class TestDetectLanguageSkipDirs: + def test_node_modules_ignored(self, tmp_path: Path) -> None: + # Two real .py files at the root, plus a noisy node_modules tree. + # If skip_dirs weren't honoured, JS would (wrongly) win. + _write(tmp_path / "main.py") + _write(tmp_path / "lib.py") + for i in range(20): + _write(tmp_path / "node_modules" / f"pkg_{i}" / "index.js") + assert detect_language(str(tmp_path)) == "python" + + def test_vendor_ignored(self, tmp_path: Path) -> None: + _write(tmp_path / "cmd" / "main.go") + _write(tmp_path / "internal" / "svc.go") + for i in range(20): + _write(tmp_path / "vendor" / f"dep_{i}" / "lib.py") + assert detect_language(str(tmp_path)) == "go" + + +class TestDetectLanguageEmpty: + def test_empty_directory_raises(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="No supported source files"): + detect_language(str(tmp_path)) + + def test_only_unsupported_files_raises(self, tmp_path: Path) -> None: + _write(tmp_path / "README.md", "# hi") + _write(tmp_path / "data.json", "{}") + with pytest.raises(ValueError, match="No supported source files"): + detect_language(str(tmp_path)) + + +class TestDetectLanguageNonGit: + """Auto-detection is purely extension-based and must not require .git.""" + + def test_non_git_directory_detected(self, tmp_path: Path) -> None: + _write(tmp_path / "main.py") + assert not (tmp_path / ".git").exists() + assert detect_language(str(tmp_path)) == "python" diff --git a/libs/openant-core/tests/test_go_cli.py b/libs/openant-core/tests/test_go_cli.py index 42ad294e..dcefece6 100644 --- a/libs/openant-core/tests/test_go_cli.py +++ b/libs/openant-core/tests/test_go_cli.py @@ -166,3 +166,205 @@ def test_scan_requires_api_key(self, sample_python_repo): output = result.stderr + result.stdout assert result.returncode != 0 assert "api key" in output.lower() + + +class TestInit: + """Integration tests for ``openant init`` covering item 13 of #16: + auto-detect language and tolerate non-git directories. + """ + + @pytest.fixture + def isolated_home(self, tmp_path): + """Override home so init writes into a tmp ~/.openant/.""" + home = str(tmp_path / "fakehome") + os.makedirs(home) + # USERPROFILE for Windows, HOME for Unix. + return {"USERPROFILE": home, "HOME": home} + + def _read_project_json(self, home_dir, project_name): + project_json = ( + Path(home_dir) + / ".openant" + / "projects" + / project_name + / "project.json" + ) + assert project_json.exists(), ( + f"project.json not found at {project_json}" + ) + return json.loads(project_json.read_text()) + + @staticmethod + def _make_repo(tmp_path, name, files): + repo = tmp_path / name + repo.mkdir() + for rel, content in files.items(): + target = repo / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + return repo + + def test_auto_detect_python_from_fixture( + self, sample_python_repo, isolated_home + ): + """Init with -l auto on a Python fixture detects ``python``.""" + result = run_cli( + "init", sample_python_repo, + "--name", "test/python-repo", + "-l", "auto", + env_override=isolated_home, + ) + assert result.returncode == 0, f"init failed:\n{result.stderr}" + assert "Detected language: python" in result.stderr + + project = self._read_project_json( + isolated_home["HOME"], "test/python-repo", + ) + assert project["language"] == "python" + + def test_auto_detect_javascript_from_fixture( + self, sample_js_repo, isolated_home + ): + """Init with -l auto on a JS fixture detects ``javascript``.""" + result = run_cli( + "init", sample_js_repo, + "--name", "test/js-repo", + "-l", "auto", + env_override=isolated_home, + ) + assert result.returncode == 0, f"init failed:\n{result.stderr}" + assert "Detected language: javascript" in result.stderr + + project = self._read_project_json( + isolated_home["HOME"], "test/js-repo", + ) + assert project["language"] == "javascript" + + def test_auto_detect_typescript_synthetic(self, tmp_path, isolated_home): + """A TS-only tree (no .git) is detected as ``javascript``.""" + repo = self._make_repo( + tmp_path, "ts_repo", + { + "src/app.ts": "export const x = 1;\n", + "src/comp.tsx": "export default () => null;\n", + "src/util.ts": "export const y = 2;\n", + }, + ) + result = run_cli( + "init", str(repo), + "--name", "test/ts-synth", + "-l", "auto", + env_override=isolated_home, + ) + assert result.returncode == 0, f"init failed:\n{result.stderr}" + assert "Detected language: javascript" in result.stderr + + project = self._read_project_json( + isolated_home["HOME"], "test/ts-synth", + ) + assert project["language"] == "javascript" + + def test_auto_detect_go_synthetic(self, tmp_path, isolated_home): + """A Go-only tree (no .git) is detected as ``go``.""" + repo = self._make_repo( + tmp_path, "go_repo", + { + "main.go": "package main\nfunc main() {}\n", + "internal/svc.go": "package internal\n", + "cmd/cli.go": "package cmd\n", + }, + ) + result = run_cli( + "init", str(repo), + "--name", "test/go-synth", + "-l", "auto", + env_override=isolated_home, + ) + assert result.returncode == 0, f"init failed:\n{result.stderr}" + assert "Detected language: go" in result.stderr + + project = self._read_project_json( + isolated_home["HOME"], "test/go-synth", + ) + assert project["language"] == "go" + + def test_explicit_language_overrides_auto_detect( + self, sample_python_repo, isolated_home + ): + """An explicit ``-l`` flag wins over auto-detection.""" + result = run_cli( + "init", sample_python_repo, + "--name", "test/explicit-lang", + "-l", "go", + env_override=isolated_home, + ) + assert result.returncode == 0, f"init failed:\n{result.stderr}" + # Auto-detect path must not run when -l is supplied. + assert "Auto-detecting" not in result.stderr + + project = self._read_project_json( + isolated_home["HOME"], "test/explicit-lang", + ) + assert project["language"] == "go" + + def test_non_git_directory_uses_nogit_sha(self, tmp_path, isolated_home): + """Init on a plain (non-.git) dir succeeds with ``nogit`` placeholder.""" + repo = self._make_repo( + tmp_path, "plain_repo", + {"main.py": "print('hello')\n"}, + ) + # Sanity: not a git repo. + assert not (repo / ".git").exists() + + result = run_cli( + "init", str(repo), + "--name", "test/no-git", + "-l", "auto", + env_override=isolated_home, + ) + assert result.returncode == 0, f"init failed:\n{result.stderr}" + + project = self._read_project_json( + isolated_home["HOME"], "test/no-git", + ) + assert project["language"] == "python" + assert project["commit_sha"] == "nogit" + assert project["commit_sha_short"] == "nogit" + + def test_non_git_directory_warns_on_commit_flag( + self, tmp_path, isolated_home + ): + """``--commit`` on a non-git directory warns and falls back to ``nogit``.""" + repo = self._make_repo( + tmp_path, "plain_repo", + {"main.py": "print('hello')\n"}, + ) + result = run_cli( + "init", str(repo), + "--name", "test/no-git-commit", + "--commit", "abc123", + "-l", "auto", + env_override=isolated_home, + ) + assert result.returncode == 0, f"init failed:\n{result.stderr}" + assert "ignored" in result.stderr.lower() + + project = self._read_project_json( + isolated_home["HOME"], "test/no-git-commit", + ) + assert project["commit_sha"] == "nogit" + + def test_empty_dir_fails_with_clear_error(self, tmp_path, isolated_home): + """Init on a directory with no source files fails cleanly.""" + empty = tmp_path / "empty_repo" + empty.mkdir() + + result = run_cli( + "init", str(empty), + "--name", "test/empty", + "-l", "auto", + env_override=isolated_home, + ) + assert result.returncode != 0 + combined = (result.stderr + result.stdout).lower() + assert "no supported source files" in combined