diff --git a/src/agentready/assessors/base.py b/src/agentready/assessors/base.py index 3b129578..0883671e 100644 --- a/src/agentready/assessors/base.py +++ b/src/agentready/assessors/base.py @@ -1,6 +1,7 @@ """Base assessor interface for attribute evaluation.""" from abc import ABC, abstractmethod +from pathlib import Path from ..models.finding import Finding from ..models.repository import Repository @@ -67,6 +68,80 @@ def is_applicable(self, repository: Repository) -> bool: """ return True + # Root-level manifest files that strongly signal the project's primary language. + # When file counts are close, these break the tie. + _LANG_ROOT_MANIFESTS: dict[str, list[str]] = { + "Go": ["go.mod"], + "Python": ["pyproject.toml", "setup.py", "setup.cfg"], + "JavaScript": ["package.json"], + "TypeScript": ["tsconfig.json"], + } + + def _primary_language( + self, + repository: Repository, + candidates: set[str], + ) -> str | None: + """Return the primary programming language among candidates. + + Uses file count as the base signal, but when counts are within 30% + of each other, a root-level project manifest (go.mod, pyproject.toml, + package.json) acts as tiebreaker — the language whose manifest sits + at the repo root is treated as primary. + + This handles repos like Go operators with a Python SDK subdirectory, + where Python may have slightly more files but Go owns the root. + """ + lang_counts = { + lang: repository.languages.get(lang, 0) + for lang in candidates + if repository.languages.get(lang, 0) > 0 + } + if not lang_counts: + return None + + top_lang = max(lang_counts, key=lambda k: (lang_counts[k], k)) + top_count = lang_counts[top_lang] + + if top_count == 0: + return None + + # Check if any other candidate is close enough to contest + close_langs = { + lang for lang, count in lang_counts.items() if count >= top_count * 0.7 + } + if len(close_langs) > 1: + manifest_winners = [ + lang + for lang in sorted(close_langs) + if any( + (repository.path / m).exists() + for m in self._LANG_ROOT_MANIFESTS.get(lang, []) + ) + ] + if len(manifest_winners) == 1: + return manifest_winners[0] + + return top_lang + + def _find_go_module_roots(self, repository: Repository) -> list[Path]: + """Find directories containing go.mod (Go module roots). + + Supports both single-module repos (go.mod at root) and monorepos + (go.mod in subdirectories at any depth). Excludes vendor and + testdata directories. + """ + roots: list[Path] = [] + if (repository.path / "go.mod").exists(): + roots.append(repository.path) + for gomod in repository.path.rglob("go.mod"): + if "vendor" in gomod.parts or "testdata" in gomod.parts: + continue + if gomod.parent == repository.path: + continue + roots.append(gomod.parent) + return sorted(set(roots)) + def calculate_proportional_score( self, measured_value: float, diff --git a/src/agentready/assessors/code_quality.py b/src/agentready/assessors/code_quality.py index 2368cac0..2228d6e5 100644 --- a/src/agentready/assessors/code_quality.py +++ b/src/agentready/assessors/code_quality.py @@ -2,6 +2,7 @@ import ast import logging +import re from ..models.attribute import Attribute from ..models.finding import Citation, Finding, Remediation @@ -55,16 +56,17 @@ def is_applicable(self, repository: Repository) -> bool: def assess(self, repository: Repository) -> Finding: """Check type annotation coverage. - For Python: Use mypy or similar - For TypeScript: Check tsconfig.json strict mode - For others: Heuristic checks + Dispatches based on the primary programming language (by file count) + to handle multi-language repos correctly. """ - if "Python" in repository.languages: + primary = self._primary_language(repository, {"Python", "TypeScript", "Go"}) + if primary == "Python": return self._assess_python_types(repository) - elif "TypeScript" in repository.languages: + elif primary == "TypeScript": return self._assess_typescript_types(repository) + elif primary == "Go": + return self._assess_go_types(repository) else: - # For other languages, use heuristic return Finding.not_applicable( self.attribute, reason=f"Type annotation check not implemented for {list(repository.languages.keys())}", @@ -202,6 +204,157 @@ def _assess_typescript_types(self, repository: Repository) -> Finding: self.attribute, reason=f"Could not parse tsconfig.json: {str(e)}" ) + @staticmethod + def _strip_go_non_code(content: str) -> str: + """Strip comments and string literal contents from Go source. + + Preserves line structure so line-anchored regexes still work. + """ + out = [] + i = 0 + n = len(content) + while i < n: + c = content[i] + + # Block comment + if c == "/" and i + 1 < n and content[i + 1] == "*": + i += 2 + while i + 1 < n and not (content[i] == "*" and content[i + 1] == "/"): + out.append("\n" if content[i] == "\n" else " ") + i += 1 + out.append(" ") # * + i += 1 + if i < n: + out.append(" ") # / + i += 1 + continue + + # Line comment + if c == "/" and i + 1 < n and content[i + 1] == "/": + i += 2 + while i < n and content[i] != "\n": + i += 1 + continue + + # Double-quoted string + if c == '"': + out.append(c) + i += 1 + while i < n and content[i] != '"': + if content[i] == "\\" and i + 1 < n: + out.append(" ") + out.append(" ") + i += 2 + else: + out.append(" ") + i += 1 + if i < n: + out.append(c) + i += 1 + continue + + # Raw string (backtick) + if c == "`": + out.append(c) + i += 1 + while i < n and content[i] != "`": + out.append("\n" if content[i] == "\n" else " ") + i += 1 + if i < n: + out.append(c) + i += 1 + continue + + out.append(c) + i += 1 + + return "".join(out) + + def _assess_go_types(self, repository: Repository) -> Finding: + """Assess Go type safety. + + Go is statically typed at compile time. Score starts at 100 and + deducts for excessive use of interface{}/any which weakens type safety. + """ + from ..utils.subprocess_utils import safe_subprocess_run + + try: + result = safe_subprocess_run( + ["git", "ls-files", "*.go"], + cwd=repository.path, + capture_output=True, + text=True, + timeout=30, + check=True, + ) + go_files = [ + f + for f in result.stdout.strip().split("\n") + if f and not f.endswith("_test.go") + ] + except Exception: + go_files = [ + str(f.relative_to(repository.path)) + for f in repository.path.rglob("*.go") + if not f.name.endswith("_test.go") + ] + + if not go_files: + return Finding.not_applicable( + self.attribute, reason="No Go source files found" + ) + + total_funcs = 0 + any_usage_count = 0 + + for file_path in go_files: + full_path = repository.path / file_path + try: + content = full_path.read_text(encoding="utf-8") + code_content = self._strip_go_non_code(content) + total_funcs += len(re.findall(r"^func\s+", code_content, re.MULTILINE)) + any_usage_count += len( + re.findall(r"\binterface\s*\{\s*\}|\bany\b", code_content) + ) + except (OSError, UnicodeDecodeError): + continue + + evidence = ["Go enforces types at compile time (statically typed)"] + + if total_funcs == 0: + score = 100.0 + elif any_usage_count == 0: + score = 100.0 + evidence.append("No interface{}/any usage found — strong type safety") + else: + ratio = any_usage_count / max(total_funcs, 1) + if ratio < 0.1: + score = 95.0 + evidence.append( + f"Minimal interface{{}}/any usage: {any_usage_count} occurrences" + ) + elif ratio < 0.25: + score = 85.0 + evidence.append( + f"Moderate interface{{}}/any usage: {any_usage_count} occurrences" + ) + else: + score = 70.0 + evidence.append( + f"Heavy interface{{}}/any usage: {any_usage_count} occurrences — consider using generics" + ) + + return Finding( + attribute=self.attribute, + status="pass" if score >= 75 else "fail", + score=score, + measured_value=f"{score:.0f}%", + threshold="≥80%", + evidence=evidence, + remediation=None, + error_message=None, + ) + def _create_remediation(self) -> Remediation: """Create remediation guidance for type annotations.""" return Remediation( @@ -283,16 +436,18 @@ def attribute(self) -> Attribute: ) def is_applicable(self, repository: Repository) -> bool: - """Applicable to languages supported by radon or lizard.""" - supported = {"Python", "JavaScript", "TypeScript", "C", "C++", "Java"} + """Applicable to languages supported by radon, lizard, or gocyclo.""" + supported = {"Python", "JavaScript", "TypeScript", "C", "C++", "Java", "Go"} return bool(set(repository.languages.keys()) & supported) def assess(self, repository: Repository) -> Finding: - """Check cyclomatic complexity using radon or lizard.""" - if "Python" in repository.languages: + """Check cyclomatic complexity using radon, lizard, or gocyclo.""" + primary = self._primary_language(repository, {"Python", "Go"}) + if primary == "Python": return self._assess_python_complexity(repository) + elif primary == "Go": + return self._assess_go_complexity(repository) else: - # Use lizard for other languages return self._assess_with_lizard(repository) def _assess_python_complexity(self, repository: Repository) -> Finding: @@ -386,6 +541,114 @@ def _assess_with_lizard(self, repository: Repository) -> Finding: self.attribute, reason=f"Complexity analysis failed: {str(e)}" ) + def _assess_go_complexity(self, repository: Repository) -> Finding: + """Assess Go complexity using gocyclo or golangci-lint config detection. + + Tries gocyclo first. Falls back to checking if golangci-lint has + complexity linters (gocyclo/cyclop) enabled in config. + """ + try: + result = safe_subprocess_run( + ["gocyclo", "-avg", str(repository.path)], + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode == 0 and result.stdout.strip(): + lines = result.stdout.strip().split("\n") + avg_line = [line for line in lines if "Average" in line] + if avg_line: + avg_value = float(avg_line[0].split()[-1]) + + score = self.calculate_proportional_score( + measured_value=avg_value, + threshold=10.0, + higher_is_better=False, + ) + status = "pass" if score >= 75 else "fail" + + return Finding( + attribute=self.attribute, + status=status, + score=score, + measured_value=f"{avg_value:.1f}", + threshold="<10.0", + evidence=[ + f"Average cyclomatic complexity (gocyclo): {avg_value:.1f}" + ], + remediation=( + self._create_go_complexity_remediation() + if status == "fail" + else None + ), + error_message=None, + ) + except (FileNotFoundError, Exception): + pass + + # Fallback: check if golangci-lint has complexity linters configured + # Search root and Go module root directories + search_dirs = [repository.path] + self._find_go_module_roots(repository) + for search_dir in search_dirs: + for config_name in [ + ".golangci.yml", + ".golangci.yaml", + ".golangci.toml", + ]: + config_path = search_dir / config_name + if config_path.exists(): + try: + content = config_path.read_text(encoding="utf-8") + has_complexity = bool( + re.search(r"\b(gocyclo|cyclop|gocognit)\b", content) + ) + if has_complexity: + rel = config_path.relative_to(repository.path) + return Finding( + attribute=self.attribute, + status="pass", + score=80.0, + measured_value="configured", + threshold="complexity linter enabled", + evidence=[f"Complexity linter configured in {rel}"], + remediation=None, + error_message=None, + ) + except (OSError, UnicodeDecodeError): + continue + + raise MissingToolError( + "gocyclo", + install_command="go install github.com/fzipp/gocyclo/cmd/gocyclo@latest", + ) + + def _create_go_complexity_remediation(self) -> Remediation: + """Create remediation guidance for Go complexity.""" + return Remediation( + summary="Reduce cyclomatic complexity in Go functions", + steps=[ + "Identify functions with complexity >15", + "Break complex functions into smaller, focused functions", + "Use early returns to reduce nesting", + "Extract switch/case logic into separate functions or maps", + ], + tools=["gocyclo", "golangci-lint"], + commands=[ + "go install github.com/fzipp/gocyclo/cmd/gocyclo@latest", + "gocyclo -over 15 .", + ], + examples=[], + citations=[ + Citation( + source="Go Community", + title="gocyclo - Cyclomatic Complexity for Go", + url="https://github.com/fzipp/gocyclo", + relevance="Go cyclomatic complexity analysis tool", + ) + ], + ) + def _create_remediation(self) -> Remediation: """Create remediation guidance for high complexity.""" return Remediation( @@ -453,9 +716,11 @@ def is_applicable(self, repository: Repository) -> bool: def assess(self, repository: Repository) -> Finding: """Check for structured logging library usage.""" - # Check Python dependencies - if "Python" in repository.languages: + primary = self._primary_language(repository, {"Python", "Go"}) + if primary == "Python": return self._assess_python_logging(repository) + elif primary == "Go": + return self._assess_go_logging(repository) else: return Finding.not_applicable( self.attribute, @@ -525,6 +790,123 @@ def _assess_python_logging(self, repository: Repository) -> Finding: error_message=None, ) + def _assess_go_logging(self, repository: Repository) -> Finding: + """Check for Go structured logging libraries in go.mod and source.""" + go_structured_libs = { + "go.uber.org/zap": "zap", + "github.com/sirupsen/logrus": "logrus", + "github.com/rs/zerolog": "zerolog", + "golang.org/x/exp/slog": "slog (experimental)", + } + + found_libs = [] + + module_roots = self._find_go_module_roots(repository) + if not module_roots: + return Finding.not_applicable(self.attribute, reason="No go.mod found") + + for root in module_roots: + try: + mod_content = (root / "go.mod").read_text(encoding="utf-8") + for lib_path, lib_name in go_structured_libs.items(): + if lib_path in mod_content: + found_libs.append(lib_name) + except (OSError, UnicodeDecodeError): + pass + + # Check for stdlib log/slog (Go 1.21+, no go.mod entry needed) + if not found_libs: + from ..utils.subprocess_utils import safe_subprocess_run + + try: + result = safe_subprocess_run( + ["git", "ls-files", "*.go"], + cwd=repository.path, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + for f in result.stdout.strip().split("\n"): + if not f or f.endswith("_test.go"): + continue + try: + content = (repository.path / f).read_text(encoding="utf-8") + if '"log/slog"' in content: + found_libs.append("slog (stdlib)") + break + except (OSError, UnicodeDecodeError): + continue + except Exception: + pass + + if found_libs: + return Finding( + attribute=self.attribute, + status="pass", + score=100.0, + measured_value="configured", + threshold="structured logging library", + evidence=[ + f"Structured logging library found: {', '.join(set(found_libs))}", + "Checked: go.mod and source imports", + ], + remediation=None, + error_message=None, + ) + + return Finding( + attribute=self.attribute, + status="fail", + score=0.0, + measured_value="not configured", + threshold="structured logging library", + evidence=[ + "No structured logging library found in go.mod", + "Go stdlib log package produces unstructured output", + ], + remediation=self._create_go_logging_remediation(), + error_message=None, + ) + + def _create_go_logging_remediation(self) -> Remediation: + """Create remediation guidance for Go structured logging.""" + return Remediation( + summary="Add structured logging library for Go", + steps=[ + "Choose a structured logging library (slog for Go 1.21+, zap for high-performance)", + "Configure JSON output for production", + "Use consistent field naming across the codebase", + ], + tools=["slog", "zap", "zerolog"], + commands=[ + "# Option A: Use stdlib slog (Go 1.21+)", + '# No installation needed — import "log/slog"', + "", + "# Option B: Use zap", + "go get go.uber.org/zap", + ], + examples=[ + """// Using Go stdlib slog (Go 1.21+) +import "log/slog" + +logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) +logger.Info("user_login", + slog.String("user_id", "123"), + slog.String("ip", remoteAddr), +) +""", + ], + citations=[ + Citation( + source="Go Documentation", + title="log/slog package", + url="https://pkg.go.dev/log/slog", + relevance="Go stdlib structured logging (Go 1.21+)", + ), + ], + ) + def _create_remediation(self) -> Remediation: """Create remediation guidance for structured logging.""" return Remediation( @@ -648,10 +1030,15 @@ def _has_rubocop(self, repository: Repository) -> bool: ).exists() def _has_golangci_lint(self, repository: Repository) -> bool: - """Check for golangci-lint configuration.""" - return (repository.path / ".golangci.yml").exists() or ( - repository.path / ".golangci.yaml" - ).exists() + """Check for golangci-lint configuration at root or in Go module dirs.""" + config_names = [ + ".golangci.yml", + ".golangci.yaml", + ".golangci.toml", + ".golangci.json", + ] + search_dirs = [repository.path] + self._find_go_module_roots(repository) + return any((d / name).exists() for d in search_dirs for name in config_names) def _has_actionlint(self, repository: Repository) -> bool: """Check for actionlint in pre-commit or GitHub Actions.""" diff --git a/src/agentready/assessors/documentation.py b/src/agentready/assessors/documentation.py index c47fa8a5..112945a6 100644 --- a/src/agentready/assessors/documentation.py +++ b/src/agentready/assessors/documentation.py @@ -479,8 +479,7 @@ def _create_remediation(self) -> Remediation: ], tools=[], commands=[], - examples=[ - """# Project Name + examples=["""# Project Name ## Overview What this project does and why it exists. @@ -503,8 +502,7 @@ def _create_remediation(self) -> Remediation: # Format code black . ``` -""" - ], +"""], citations=[ Citation( source="GitHub", @@ -1081,21 +1079,25 @@ def attribute(self) -> Attribute: ) def is_applicable(self, repository: Repository) -> bool: - """Only applicable to languages with docstring conventions.""" - applicable_languages = {"Python", "JavaScript", "TypeScript"} + """Only applicable to languages with documentation conventions.""" + applicable_languages = {"Python", "JavaScript", "TypeScript", "Go"} return bool(set(repository.languages.keys()) & applicable_languages) def assess(self, repository: Repository) -> Finding: - """Check docstring coverage for public functions and classes. + """Check documentation coverage for public functions and classes. - Currently supports Python only. JavaScript/TypeScript can be added later. + Dispatches based on the primary programming language (by file count) + to handle multi-language repos correctly. """ - if "Python" in repository.languages: + primary = self._primary_language(repository, {"Python", "Go"}) + if primary == "Python": return self._assess_python_docstrings(repository) + elif primary == "Go": + return self._assess_go_godoc(repository) else: return Finding.not_applicable( self.attribute, - reason=f"Docstring check not implemented for {list(repository.languages.keys())}", + reason=f"Documentation check not implemented for {list(repository.languages.keys())}", ) def _assess_python_docstrings(self, repository: Repository) -> Finding: @@ -1190,6 +1192,160 @@ def _assess_python_docstrings(self, repository: Repository) -> Finding: error_message=None, ) + @staticmethod + def _has_go_doc_comment( + lines: list[str], symbol_idx: int, symbol_name: str + ) -> bool: + """Check if an exported Go symbol has a doc comment above it. + + Matches go doc behavior: any comment immediately preceding a + declaration (no blank line between) is a doc comment. + """ + prev_idx = symbol_idx - 1 + while prev_idx >= 0 and not lines[prev_idx].strip(): + prev_idx -= 1 + if prev_idx < 0: + return False + + prev_line = lines[prev_idx].strip() + + if prev_line.startswith("//"): + return True + + if prev_line.endswith("*/"): + return True + + return False + + def _assess_go_godoc(self, repository: Repository) -> Finding: + """Assess Go godoc comment coverage on exported symbols. + + Go convention: exported symbols (starting with uppercase) should have + a comment directly above starting with the symbol name. + """ + import re + + try: + result = safe_subprocess_run( + ["git", "ls-files", "*.go"], + cwd=repository.path, + capture_output=True, + text=True, + timeout=30, + check=True, + ) + go_files = [ + f + for f in result.stdout.strip().split("\n") + if f and not f.endswith("_test.go") and "vendor/" not in f + ] + except Exception: + go_files = [ + str(f.relative_to(repository.path)) + for f in repository.path.rglob("*.go") + if not f.name.endswith("_test.go") and "vendor" not in f.parts + ] + + total_exported = 0 + documented_exported = 0 + has_doc_go = False + + # Match exported functions (including methods with receivers), types, + # vars, and consts. Group 1 captures the exported symbol name. + exported_pattern = re.compile( + r"^(?:func\s+(?:\([^)]+\)\s+)?|type\s+|var\s+|const\s+)([A-Z]\w*)" + ) + + for file_path in go_files: + full_path = repository.path / file_path + if full_path.name == "doc.go": + has_doc_go = True + + try: + lines = full_path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + + for i, line in enumerate(lines): + match = exported_pattern.match(line.strip()) + if not match: + continue + + total_exported += 1 + symbol_name = match.group(1) + + if i > 0 and self._has_go_doc_comment(lines, i, symbol_name): + documented_exported += 1 + + if total_exported == 0: + return Finding.not_applicable( + self.attribute, + reason="No exported Go symbols found", + ) + + coverage_percent = (documented_exported / total_exported) * 100 + score = self.calculate_proportional_score( + measured_value=coverage_percent, + threshold=80.0, + higher_is_better=True, + ) + + status = "pass" if score >= 75 else "fail" + + evidence = [ + f"Documented exports: {documented_exported}/{total_exported}", + f"Godoc coverage: {coverage_percent:.1f}%", + ] + if has_doc_go: + evidence.append("doc.go package documentation found") + + return Finding( + attribute=self.attribute, + status=status, + score=score, + measured_value=f"{coverage_percent:.1f}%", + threshold="≥80%", + evidence=evidence, + remediation=self._create_go_remediation() if status == "fail" else None, + error_message=None, + ) + + def _create_go_remediation(self) -> Remediation: + """Create remediation guidance for missing Go godoc comments.""" + return Remediation( + summary="Add godoc comments to exported Go symbols", + steps=[ + "Add comments above all exported functions, types, and constants", + "Comments must start with the symbol name (Go convention)", + "Add doc.go files for package-level documentation", + "Add Example functions in test files for executable docs", + ], + tools=[], + commands=[ + "# Check for missing documentation", + "go vet ./...", + ], + examples=[ + """// Handler processes incoming HTTP requests and routes them +// to the appropriate service method. +func Handler(w http.ResponseWriter, r *http.Request) { + // ... +} + +// ErrNotFound is returned when the requested resource does not exist. +var ErrNotFound = errors.New("not found") +""", + ], + citations=[ + Citation( + source="Go Documentation", + title="Effective Go - Commentary", + url="https://go.dev/doc/effective_go#commentary", + relevance="Go documentation conventions", + ), + ], + ) + def _create_remediation(self) -> Remediation: """Create remediation guidance for missing docstrings.""" return Remediation( diff --git a/src/agentready/assessors/structure.py b/src/agentready/assessors/structure.py index 6409ecf4..365e4f0f 100644 --- a/src/agentready/assessors/structure.py +++ b/src/agentready/assessors/structure.py @@ -117,9 +117,13 @@ def assess(self, repository: Repository) -> Finding: - Python: src/ or project-named directory, plus tests/ - JavaScript: src/, test/, docs/ - Java: src/main/java, src/test/java + - Go: cmd/, internal/, pkg/ with go.mod, tests in *_test.go Fix for #246, #305: Support multiple valid Python layouts """ + if self._primary_language(repository, {"Python", "Go"}) == "Go": + return self._assess_go_layout(repository) + # Check for tests directory (either tests/ or test/) tests_path = repository.path / "tests" has_tests = tests_path.exists() @@ -327,6 +331,166 @@ def _is_test_only_repository(self, repository: Repository) -> bool: return has_test_config + def _assess_go_layout(self, repository: Repository) -> Finding: + """Assess Go project layout. + + Supports both single-module repos (go.mod at root) and monorepos + (go.mod in subdirectories). Checks for standard Go directories + across all module roots. + """ + score = 0.0 + evidence = [] + + module_roots = self._find_go_module_roots(repository) + has_go_mod = len(module_roots) > 0 + + if has_go_mod: + score += 30.0 + if len(module_roots) == 1 and module_roots[0] == repository.path: + evidence.append("go.mod: ✓") + else: + names = [r.name for r in module_roots] + evidence.append(f"go.mod: ✓ (monorepo: {', '.join(names)})") + else: + evidence.append("go.mod: ✗ (required for Go modules)") + + # Check for standard Go directories across all module roots + has_cmd = any((r / "cmd").is_dir() for r in module_roots) + has_internal = any((r / "internal").is_dir() for r in module_roots) + has_pkg = any((r / "pkg").is_dir() for r in module_roots) or any( + # api/ directories with Go types are also valid source layout + (r / "api").is_dir() + for r in module_roots + ) + has_root_main = any((r / "main.go").exists() for r in module_roots) + + if has_cmd: + score += 25.0 + evidence.append("cmd/: ✓ (executable entry points)") + elif has_root_main: + score += 20.0 + evidence.append("main.go: ✓ (simple single-binary project)") + else: + evidence.append("cmd/ or main.go: ✗") + + if has_internal: + score += 25.0 + evidence.append("internal/: ✓ (compiler-enforced encapsulation)") + elif has_pkg: + score += 20.0 + evidence.append("pkg/ or api/: ✓ (package structure)") + else: + evidence.append("internal/ or pkg/: ✗ (no package encapsulation)") + + from ..utils.subprocess_utils import safe_subprocess_run + + has_tests = False + try: + result = safe_subprocess_run( + ["git", "ls-files", "*_test.go", "**/*_test.go"], + cwd=repository.path, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + test_files = [f for f in result.stdout.strip().split("\n") if f] + has_tests = len(test_files) > 0 + except Exception: + has_tests = bool(list(repository.path.rglob("*_test.go"))) + + if has_tests: + score += 20.0 + evidence.append("*_test.go files: ✓ (tests alongside source)") + else: + evidence.append("*_test.go files: ✗ (no test files found)") + + score = min(score, 100.0) + status = "pass" if score >= 75 else "fail" + + return Finding( + attribute=self.attribute, + status=status, + score=score, + measured_value=f"{score:.0f}/100", + threshold="go.mod + structured source + tests", + evidence=evidence, + remediation=( + self._create_go_remediation( + has_go_mod, has_cmd, has_internal, has_tests + ) + if status == "fail" + else None + ), + error_message=None, + ) + + def _create_go_remediation( + self, + has_go_mod: bool, + has_cmd: bool, + has_internal: bool, + has_tests: bool, + ) -> Remediation: + """Create remediation guidance for Go project layout.""" + steps = [] + commands = [] + + if not has_go_mod: + steps.append("Initialize Go modules with go mod init") + commands.append("go mod init github.com/yourorg/yourproject") + + if not has_cmd: + steps.append("Create cmd/ directory for executable entry points") + commands.extend( + ["mkdir -p cmd/yourapp", "# Move main.go into cmd/yourapp/"] + ) + + if not has_internal: + steps.append( + "Create internal/ for private packages (compiler-enforced encapsulation)" + ) + commands.append("mkdir -p internal/") + + if not has_tests: + steps.append("Add test files alongside source code (*_test.go)") + commands.append("# Create test files: yourpackage/handler_test.go") + + return Remediation( + summary="Organize Go project into standard layout", + steps=steps, + tools=[], + commands=commands, + examples=[ + """# Standard Go project layout +project/ +├── cmd/ +│ └── myapp/ +│ └── main.go +├── internal/ +│ ├── handler/ +│ │ ├── handler.go +│ │ └── handler_test.go +│ └── service/ +│ ├── service.go +│ └── service_test.go +├── pkg/ (optional, for public library code) +│ └── client/ +│ └── client.go +├── go.mod +└── go.sum +""", + ], + citations=[ + Citation( + source="Go Documentation", + title="Organizing a Go module", + url="https://go.dev/doc/modules/layout", + relevance="Official Go project layout guidance", + ) + ], + ) + def _create_remediation(self, has_source: bool, has_tests: bool) -> Remediation: """Create context-aware remediation guidance for standard layout. diff --git a/src/agentready/assessors/stub_assessors.py b/src/agentready/assessors/stub_assessors.py index dbec8079..29d3a251 100644 --- a/src/agentready/assessors/stub_assessors.py +++ b/src/agentready/assessors/stub_assessors.py @@ -64,6 +64,13 @@ def assess(self, repository: Repository) -> Finding: found_strict = [f for f in strict_lock_files if (repository.path / f).exists()] found_manual = [f for f in manual_lock_files if (repository.path / f).exists()] + # Check subdirectories for Go monorepos (go.sum in module dirs) + if "go.sum" not in found_strict: + for gosum in repository.path.rglob("go.sum"): + if "vendor" in gosum.parts: + continue + found_strict.append(str(gosum.relative_to(repository.path))) + if not found_strict and not found_manual: return Finding( attribute=self.attribute, @@ -426,6 +433,9 @@ def _get_expected_patterns(self, languages: set[str]) -> list[str]: "*.test", "vendor/", "*.out", + "bin/", + "cover.out", + "coverage.txt", ], "Ruby": [ "*.gem", diff --git a/src/agentready/assessors/testing.py b/src/agentready/assessors/testing.py index 9ee0638b..d16a108f 100644 --- a/src/agentready/assessors/testing.py +++ b/src/agentready/assessors/testing.py @@ -46,19 +46,25 @@ def is_applicable(self, repository: Repository) -> bool: return True if (repository.path / "package.json").exists(): return True + if self._has_go_test_files(repository): + return True return False def assess(self, repository: Repository) -> Finding: """Check for test coverage configuration and actual coverage. - Looks for: - - Python: pytest.ini, .coveragerc, pyproject.toml with coverage config - - JavaScript: jest.config.js, package.json with coverage threshold + Dispatches based on the primary programming language (by file count) + to handle multi-language repos correctly. """ - if "Python" in repository.languages: + primary = self._primary_language( + repository, {"Python", "JavaScript", "TypeScript", "Go"} + ) + if primary == "Python": return self._assess_python_coverage(repository) - elif any(lang in repository.languages for lang in ["JavaScript", "TypeScript"]): + elif primary in ("JavaScript", "TypeScript"): return self._assess_javascript_coverage(repository) + elif primary == "Go": + return self._assess_go_coverage(repository) else: return Finding.not_applicable( self.attribute, @@ -313,6 +319,161 @@ def _has_js_coverage_threshold(self, repository: Repository, pkg: dict) -> bool: return False + def _has_go_test_files(self, repository: Repository) -> bool: + """Check if Go test files (*_test.go) exist.""" + from ..utils.subprocess_utils import safe_subprocess_run + + try: + result = safe_subprocess_run( + ["git", "ls-files", "*_test.go", "**/*_test.go"], + cwd=repository.path, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + files = [f for f in result.stdout.strip().split("\n") if f] + return len(files) > 0 + except Exception: + pass + return bool(list(repository.path.rglob("*_test.go"))) + + def _assess_go_coverage(self, repository: Repository) -> Finding: + """Assess Go test execution and coverage configuration. + + Scoring (additive, 100 max): + - Test files exist (*_test.go): 40 pts + - Test command found (Makefile/CI/README): 20 pts + - Coverage configured (-coverprofile): 20 pts + - Race detection (-race flag): 20 pts + """ + score = 0.0 + evidence = [] + + has_test_files = self._has_go_test_files(repository) + if has_test_files: + score += 40.0 + evidence.append("Go test files found (*_test.go)") + else: + evidence.append("No Go test files found (*_test.go)") + + text_sources = self._read_go_build_files(repository) + + has_test_cmd = bool(re.search(r"(?:\bgo|\$\(GO\))\s+test\b", text_sources)) + if has_test_cmd: + score += 20.0 + evidence.append("Go test command found in project files") + else: + evidence.append("No 'go test' command found in Makefile/CI/README") + + has_coverage = bool( + re.search(r"(? 50 else "fail" + + return Finding( + attribute=self.attribute, + status=status, + score=score, + measured_value=( + "configured" + if has_test_files and has_test_cmd and score > 50 + else "not configured" + ), + threshold="runnable tests with coverage config", + evidence=evidence, + remediation=self._create_go_remediation() if status == "fail" else None, + error_message=None, + ) + + def _read_go_build_files(self, repository: Repository) -> str: + """Read Makefiles, CI configs, and README for Go test patterns. + + Checks root and subdirectory Makefiles to support Go monorepos + where go.mod and Makefile live in subdirectories. + """ + contents = [] + files_to_check: list[Path] = [ + repository.path / "Makefile", + repository.path / "Taskfile.yml", + repository.path / "README.md", + ] + + # Include module-local build files (Go monorepos) + for module_root in self._find_go_module_roots(repository): + if module_root == repository.path: + continue + files_to_check.extend( + [ + module_root / "Makefile", + module_root / "Taskfile.yml", + module_root / "README.md", + ] + ) + + ci_dir = repository.path / ".github" / "workflows" + if ci_dir.exists(): + files_to_check.extend(ci_dir.glob("*.yml")) + files_to_check.extend(ci_dir.glob("*.yaml")) + + for f in files_to_check: + if f.exists(): + try: + contents.append(f.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + pass + return "\n".join(contents) + + def _create_go_remediation(self) -> Remediation: + """Create remediation guidance for Go test coverage.""" + return Remediation( + summary="Configure Go test execution with coverage and race detection", + steps=[ + "Create test files alongside source code (*_test.go)", + "Add a Makefile target for running tests with coverage", + "Enable race detection in CI with -race flag", + "Configure coverage reporting in CI pipeline", + ], + tools=["go test"], + commands=[ + "go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...", + "go tool cover -html=coverage.txt -o coverage.html", + ], + examples=[ + """# Makefile +.PHONY: test +test: +\tgo test -v -race -coverprofile=coverage.txt -covermode=atomic ./... + +.PHONY: coverage +coverage: test +\tgo tool cover -html=coverage.txt -o coverage.html +""", + ], + citations=[ + Citation( + source="Go Documentation", + title="Testing", + url="https://pkg.go.dev/testing", + relevance="Go testing package reference", + ) + ], + ) + def _create_remediation(self) -> Remediation: """Create remediation guidance for test coverage.""" return Remediation( @@ -323,7 +484,7 @@ def _create_remediation(self) -> Remediation: "Add coverage reporting to CI/CD pipeline", "Run coverage locally before committing", ], - tools=["pytest-cov", "jest", "vitest", "coverage"], + tools=["pytest-cov", "jest", "vitest", "coverage", "go test"], commands=[ "# Python", "pip install pytest-cov", @@ -332,6 +493,9 @@ def _create_remediation(self) -> Remediation: "# JavaScript", "npm install --save-dev jest", "npm test -- --coverage --coverageThreshold='{\\'global\\': {\\'lines\\': 80}}'", + "", + "# Go", + "go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...", ], examples=[ """# Python - pyproject.toml @@ -672,15 +836,17 @@ def _assess_quality_gates(self, ci_configs: list) -> tuple: r"\bmocha\b", r"\bnpm\s+test\b", r"\byarn\s+test\b", - r"\bgo\s+test\b", + r"(?:\bgo|\$\(GO\))\s+test\b", r"\bcargo\s+test\b", r"\brspec\b", + r"\bmake\s+test\b", ] typecheck_patterns = [ r"\bmypy\b", r"\bpyright\b", r"\btsc\b", r"\btype[_-]?check\b", + r"\bgo\s+vet\b", ] found_lint = False diff --git a/src/agentready/templates/bootstrap/go/workflows/security.yml.j2 b/src/agentready/templates/bootstrap/go/workflows/security.yml.j2 index 1228e2c1..0cc256d5 100644 --- a/src/agentready/templates/bootstrap/go/workflows/security.yml.j2 +++ b/src/agentready/templates/bootstrap/go/workflows/security.yml.j2 @@ -14,10 +14,15 @@ - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.24' - name: Run Gosec Security Scanner uses: securego/gosec@master with: args: '-no-fail -fmt sarif -out results.sarif ./...' + + - name: Run govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.3.0 + govulncheck ./... {% endblock %} diff --git a/src/agentready/templates/bootstrap/go/workflows/tests.yml.j2 b/src/agentready/templates/bootstrap/go/workflows/tests.yml.j2 index ad843e67..5962e717 100644 --- a/src/agentready/templates/bootstrap/go/workflows/tests.yml.j2 +++ b/src/agentready/templates/bootstrap/go/workflows/tests.yml.j2 @@ -3,7 +3,7 @@ {% block strategy %} strategy: matrix: - go-version: ['1.21', '1.22'] + go-version: ['1.23', '1.24'] {% endblock %} {% block setup_environment %} @@ -29,7 +29,7 @@ run: go vet ./... - name: Run golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v9 with: version: latest {% endblock %} @@ -39,4 +39,4 @@ run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... {% endblock %} -{% block coverage_condition %}if: matrix.go-version == '1.22'{% endblock %} +{% block coverage_condition %}if: matrix.go-version == '1.24'{% endblock %} diff --git a/tests/unit/test_assessors_go.py b/tests/unit/test_assessors_go.py new file mode 100644 index 00000000..33274f49 --- /dev/null +++ b/tests/unit/test_assessors_go.py @@ -0,0 +1,1031 @@ +"""Tests for Go language support across all assessors.""" + +import subprocess + +import pytest + +from agentready.assessors.code_quality import ( + CodeSmellsAssessor, + CyclomaticComplexityAssessor, + StructuredLoggingAssessor, + TypeAnnotationsAssessor, +) +from agentready.assessors.documentation import InlineDocumentationAssessor +from agentready.assessors.structure import StandardLayoutAssessor +from agentready.assessors.testing import ( + CIQualityGatesAssessor, + TestExecutionAssessor, +) +from agentready.models.repository import Repository + + +def _make_go_repo(tmp_path, languages=None, **kwargs): + """Create a test Go repository with git init.""" + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + return Repository( + path=tmp_path, + name="test-go-repo", + url=None, + branch="main", + commit_hash="abc123", + languages=languages or {"Go": 20}, + total_files=kwargs.get("total_files", 30), + total_lines=kwargs.get("total_lines", 5000), + ) + + +def _git_add(tmp_path, *files): + """Stage files in git so git ls-files finds them.""" + for f in files: + subprocess.run( + ["git", "add", str(f)], + cwd=tmp_path, + capture_output=True, + check=True, + ) + + +# ============================================================================= +# TestExecutionAssessor — Go support +# ============================================================================= + + +class TestTestExecutionAssessorGo: + """Test Go support in TestExecutionAssessor.""" + + def test_applicable_with_go_test_files(self, tmp_path): + """Go repos with *_test.go files should be applicable.""" + repo = _make_go_repo(tmp_path) + test_file = tmp_path / "handler_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + assessor = TestExecutionAssessor() + assert assessor.is_applicable(repo) + + def test_not_applicable_without_test_files(self, tmp_path): + """Go repos without test files or test dirs should not be applicable.""" + repo = _make_go_repo(tmp_path) + go_file = tmp_path / "main.go" + go_file.write_text("package main\n") + _git_add(tmp_path, go_file) + + assessor = TestExecutionAssessor() + assert not assessor.is_applicable(repo) + + def test_go_full_score(self, tmp_path): + """Go repo with tests, command, coverage, and race detection scores 100.""" + repo = _make_go_repo(tmp_path) + + test_file = tmp_path / "handler_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + makefile = tmp_path / "Makefile" + makefile.write_text( + ".PHONY: test\n" + "test:\n" + "\tgo test -v -race -coverprofile=coverage.txt -covermode=atomic ./...\n" + ) + + assessor = TestExecutionAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert any("test files found" in e for e in finding.evidence) + assert any("Race detector enabled" in e for e in finding.evidence) + + def test_go_tests_only(self, tmp_path): + """Go repo with only test files scores partial.""" + repo = _make_go_repo(tmp_path) + + test_file = tmp_path / "main_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + assessor = TestExecutionAssessor() + finding = assessor.assess(repo) + + assert finding.score == 40.0 + assert finding.status == "fail" + + def test_go_tests_with_ci(self, tmp_path): + """Go repo with test files and CI go test command.""" + repo = _make_go_repo(tmp_path) + + test_file = tmp_path / "main_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + workflows_dir = tmp_path / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + ci_file = workflows_dir / "ci.yml" + ci_file.write_text( + "jobs:\n test:\n steps:\n" + " - run: go test -race -coverprofile=coverage.txt ./...\n" + ) + + assessor = TestExecutionAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + + def test_go_remediation_on_fail(self, tmp_path): + """Failed Go assessment includes Go-specific remediation.""" + repo = _make_go_repo(tmp_path) + + test_file = tmp_path / "main_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + assessor = TestExecutionAssessor() + finding = assessor.assess(repo) + + assert finding.remediation is not None + assert any("go test" in cmd for cmd in finding.remediation.commands) + + +# ============================================================================= +# TypeAnnotationsAssessor — Go support +# ============================================================================= + + +class TestTypeAnnotationsAssessorGo: + """Test Go support in TypeAnnotationsAssessor.""" + + def test_go_is_applicable(self, tmp_path): + """Go repos should be applicable for type annotations.""" + repo = _make_go_repo(tmp_path) + assessor = TypeAnnotationsAssessor() + assert assessor.is_applicable(repo) + + def test_go_statically_typed_high_score(self, tmp_path): + """Go repos score high because Go is statically typed.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package main\n\n" + "func HandleRequest(w http.ResponseWriter, r *http.Request) {\n" + "}\n\n" + "func ProcessData(input string) (string, error) {\n" + "\treturn input, nil\n" + "}\n" + ) + _git_add(tmp_path, go_file) + + assessor = TypeAnnotationsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 95.0 + assert any("statically typed" in e for e in finding.evidence) + + def test_go_heavy_any_usage_lower_score(self, tmp_path): + """Go repos with heavy interface{}/any usage score lower.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package main\n\n" + "func Process(data interface{}) interface{} {\n" + "\treturn data\n" + "}\n\n" + "func Transform(input any) any {\n" + "\treturn input\n" + "}\n\n" + "func Other(x any, y any) any {\n" + "\treturn x\n" + "}\n" + ) + _git_add(tmp_path, go_file) + + assessor = TypeAnnotationsAssessor() + finding = assessor.assess(repo) + + assert finding.score < 95.0 + assert any("interface{}" in e or "any" in e for e in finding.evidence) + + def test_go_no_source_files(self, tmp_path): + """Go repo with no source files returns not_applicable.""" + repo = _make_go_repo(tmp_path) + + assessor = TypeAnnotationsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "not_applicable" + + def test_non_go_repo_not_affected(self, tmp_path): + """Python repos still use the Python assessment path.""" + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + repo = Repository( + path=tmp_path, + name="test-py-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 50}, + total_files=10, + total_lines=1000, + ) + + py_file = tmp_path / "main.py" + py_file.write_text("def hello(): pass\n") + _git_add(tmp_path, py_file) + + assessor = TypeAnnotationsAssessor() + finding = assessor.assess(repo) + + # Should use Python path, not Go + assert "statically typed" not in str(finding.evidence) + + +# ============================================================================= +# StandardLayoutAssessor — Go support +# ============================================================================= + + +class TestStandardLayoutAssessorGo: + """Test Go layout detection in StandardLayoutAssessor.""" + + def test_go_full_layout(self, tmp_path): + """Go repo with go.mod + cmd/ + internal/ + tests scores high.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "go.mod").write_text("module github.com/test/repo\n\ngo 1.22\n") + (tmp_path / "cmd" / "myapp").mkdir(parents=True) + (tmp_path / "cmd" / "myapp" / "main.go").write_text("package main\n") + (tmp_path / "internal" / "handler").mkdir(parents=True) + (tmp_path / "internal" / "handler" / "handler.go").write_text( + "package handler\n" + ) + + test_file = tmp_path / "internal" / "handler" / "handler_test.go" + test_file.write_text('package handler\nimport "testing"\n') + _git_add(tmp_path, test_file) + + assessor = StandardLayoutAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert any("go.mod" in e for e in finding.evidence) + assert any("cmd/" in e for e in finding.evidence) + assert any("internal/" in e for e in finding.evidence) + + def test_go_simple_project(self, tmp_path): + """Go repo with go.mod + root main.go + tests scores well.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "go.mod").write_text("module github.com/test/repo\n\ngo 1.22\n") + (tmp_path / "main.go").write_text("package main\n") + + test_file = tmp_path / "main_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + assessor = StandardLayoutAssessor() + finding = assessor.assess(repo) + + # go.mod (30) + main.go at root (20) + tests (20) = 70 + assert finding.score >= 70.0 + assert any("main.go" in e and "✓" in e for e in finding.evidence) + + def test_go_missing_go_mod(self, tmp_path): + """Go repo without go.mod scores lower.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "main.go").write_text("package main\n") + + assessor = StandardLayoutAssessor() + finding = assessor.assess(repo) + + assert finding.score < 75.0 + assert any("go.mod" in e and "✗" in e for e in finding.evidence) + + def test_go_remediation_includes_go_commands(self, tmp_path): + """Failed Go layout assessment has Go-specific remediation.""" + repo = _make_go_repo(tmp_path) + + assessor = StandardLayoutAssessor() + finding = assessor.assess(repo) + + assert finding.remediation is not None + assert any("go mod init" in cmd for cmd in finding.remediation.commands) + + def test_go_monorepo_layout(self, tmp_path): + """Go monorepo with go.mod in subdirectories scores well.""" + repo = _make_go_repo(tmp_path) + + # Create monorepo structure + svc_a = tmp_path / "service-a" + svc_b = tmp_path / "service-b" + svc_a.mkdir() + svc_b.mkdir() + (svc_a / "go.mod").write_text("module github.com/test/service-a\n\ngo 1.22\n") + (svc_b / "go.mod").write_text("module github.com/test/service-b\n\ngo 1.22\n") + (svc_a / "cmd").mkdir() + (svc_a / "cmd" / "main.go").write_text("package main\n") + (svc_b / "internal").mkdir() + (svc_b / "internal" / "handler.go").write_text("package handler\n") + + test_file = svc_a / "handler_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + assessor = StandardLayoutAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert any("monorepo" in e for e in finding.evidence) + assert any("cmd/" in e for e in finding.evidence) + assert any("internal/" in e for e in finding.evidence) + + def test_python_repo_uses_python_layout(self, tmp_path): + """Python repos still use the Python layout detection path.""" + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + repo = Repository( + path=tmp_path, + name="test-py-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 50}, + total_files=10, + total_lines=1000, + ) + + (tmp_path / "src").mkdir() + + assessor = StandardLayoutAssessor() + finding = assessor.assess(repo) + + # Should not mention Go-specific dirs + assert not any("go.mod" in e for e in finding.evidence) + + +# ============================================================================= +# CyclomaticComplexityAssessor — Go support +# ============================================================================= + + +class TestCyclomaticComplexityAssessorGo: + """Test Go support in CyclomaticComplexityAssessor.""" + + def test_go_is_applicable(self, tmp_path): + """Go repos should be applicable for complexity checking.""" + repo = _make_go_repo(tmp_path) + assessor = CyclomaticComplexityAssessor() + assert assessor.is_applicable(repo) + + def test_go_with_golangci_lint_complexity(self, tmp_path): + """Go repo with golangci-lint cyclop/gocyclo enabled passes.""" + repo = _make_go_repo(tmp_path) + + config = tmp_path / ".golangci.yml" + config.write_text( + "linters:\n" " enable:\n" " - gocyclo\n" " - errcheck\n" + ) + + assessor = CyclomaticComplexityAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 80.0 + assert any( + "golangci" in e.lower() or "complexity" in e.lower() + for e in finding.evidence + ) + + def test_go_with_cyclop_linter(self, tmp_path): + """Go repo with cyclop linter configured passes.""" + repo = _make_go_repo(tmp_path) + + config = tmp_path / ".golangci.yaml" + config.write_text("linters:\n" " enable:\n" " - cyclop\n") + + assessor = CyclomaticComplexityAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 80.0 + + def test_go_without_tools_raises_missing_tool(self, tmp_path): + """Go repo without gocyclo or golangci-lint config raises MissingToolError.""" + from agentready.services.scanner import MissingToolError + + repo = _make_go_repo(tmp_path) + + assessor = CyclomaticComplexityAssessor() + with pytest.raises(MissingToolError): + assessor.assess(repo) + + +# ============================================================================= +# StructuredLoggingAssessor — Go support +# ============================================================================= + + +class TestStructuredLoggingAssessorGo: + """Test Go support in StructuredLoggingAssessor.""" + + def test_go_with_zap(self, tmp_path): + """Go repo using zap should pass.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "go.mod").write_text( + "module github.com/test/repo\n\n" + "go 1.22\n\n" + "require go.uber.org/zap v1.27.0\n" + ) + + assessor = StructuredLoggingAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert any("zap" in e for e in finding.evidence) + + def test_go_with_logrus(self, tmp_path): + """Go repo using logrus should pass.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "go.mod").write_text( + "module github.com/test/repo\n\n" + "go 1.22\n\n" + "require github.com/sirupsen/logrus v1.9.0\n" + ) + + assessor = StructuredLoggingAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert any("logrus" in e for e in finding.evidence) + + def test_go_with_zerolog(self, tmp_path): + """Go repo using zerolog should pass.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "go.mod").write_text( + "module github.com/test/repo\n\n" + "go 1.22\n\n" + "require github.com/rs/zerolog v1.33.0\n" + ) + + assessor = StructuredLoggingAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert any("zerolog" in e for e in finding.evidence) + + def test_go_with_slog_stdlib(self, tmp_path): + """Go repo using stdlib log/slog should pass.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "go.mod").write_text("module github.com/test/repo\n\ngo 1.22\n") + + go_file = tmp_path / "main.go" + go_file.write_text( + 'package main\n\nimport "log/slog"\n\n' + 'func main() {\n\tslog.Info("hello")\n}\n' + ) + _git_add(tmp_path, go_file) + + assessor = StructuredLoggingAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert any("slog" in e for e in finding.evidence) + + def test_go_without_structured_logging(self, tmp_path): + """Go repo without structured logging fails.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / "go.mod").write_text("module github.com/test/repo\n\ngo 1.22\n") + + assessor = StructuredLoggingAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 0.0 + assert finding.remediation is not None + + def test_go_no_go_mod(self, tmp_path): + """Go repo without go.mod returns not_applicable.""" + repo = _make_go_repo(tmp_path) + + assessor = StructuredLoggingAssessor() + finding = assessor.assess(repo) + + assert finding.status == "not_applicable" + + def test_go_monorepo_zap_in_subdir(self, tmp_path): + """Go monorepo with zap in subdirectory go.mod should pass.""" + repo = _make_go_repo(tmp_path) + + svc = tmp_path / "my-service" + svc.mkdir() + (svc / "go.mod").write_text( + "module github.com/test/my-service\n\n" + "go 1.22\n\n" + "require go.uber.org/zap v1.27.0\n" + ) + + assessor = StructuredLoggingAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert any("zap" in e for e in finding.evidence) + + +# ============================================================================= +# Go monorepo integration tests +# ============================================================================= + + +class TestGoMonorepoSupport: + """Test that assessors handle Go monorepos with go.mod in subdirectories.""" + + def test_code_smells_golangci_in_subdir(self, tmp_path): + """CodeSmellsAssessor finds .golangci.yml in Go module subdirectory.""" + repo = _make_go_repo(tmp_path) + + svc = tmp_path / "my-service" + svc.mkdir() + (svc / "go.mod").write_text("module github.com/test/svc\n\ngo 1.22\n") + (svc / ".golangci.yml").write_text("linters:\n enable:\n - errcheck\n") + + assessor = CodeSmellsAssessor() + finding = assessor.assess(repo) + + assert any("golangci-lint" in e for e in finding.evidence) + + def test_complexity_golangci_in_subdir(self, tmp_path): + """CyclomaticComplexityAssessor finds golangci-lint config in subdirectory.""" + repo = _make_go_repo(tmp_path) + + svc = tmp_path / "my-api" + svc.mkdir() + (svc / "go.mod").write_text("module github.com/test/api\n\ngo 1.22\n") + (svc / ".golangci.yml").write_text("linters:\n enable:\n - gocyclo\n") + + assessor = CyclomaticComplexityAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 70.0 + + def test_dependency_pinning_go_sum_in_subdir(self, tmp_path): + """DependencyPinningAssessor finds go.sum in subdirectory.""" + from agentready.assessors.stub_assessors import DependencyPinningAssessor + + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + repo = Repository( + path=tmp_path, + name="test-go-mono", + url=None, + branch="main", + commit_hash="abc123", + languages={"Go": 20}, + total_files=30, + total_lines=5000, + ) + + svc = tmp_path / "my-service" + svc.mkdir() + (svc / "go.mod").write_text("module github.com/test/svc\n\ngo 1.22\n") + (svc / "go.sum").write_text("github.com/some/dep v1.0.0 h1:abc=\n") + + assessor = DependencyPinningAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert any("go.sum" in e for e in finding.evidence) + + +# ============================================================================= +# InlineDocumentationAssessor — Go godoc support +# ============================================================================= + + +class TestInlineDocumentationAssessorGo: + """Test Go godoc support in InlineDocumentationAssessor.""" + + def test_go_is_applicable(self, tmp_path): + """Go repos should be applicable for inline documentation.""" + repo = _make_go_repo(tmp_path) + assessor = InlineDocumentationAssessor() + assert assessor.is_applicable(repo) + + def test_go_well_documented(self, tmp_path): + """Go repo with godoc comments on all exports scores high.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "// Handler processes incoming HTTP requests.\n" + "func Handler() {}\n\n" + "// ErrNotFound is returned when a resource is missing.\n" + 'var ErrNotFound = errors.New("not found")\n\n' + "// Config holds the application configuration.\n" + "type Config struct {}\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 90.0 + + def test_go_undocumented_exports(self, tmp_path): + """Go repo with undocumented exports scores low.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "func Handler() {}\n\n" + "func Process() {}\n\n" + "func Transform() {}\n\n" + "type Config struct {}\n\n" + "var MaxRetries = 3\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score < 75.0 + + def test_go_private_symbols_ignored(self, tmp_path): + """Private (lowercase) Go symbols should not count.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "// Handler processes requests.\n" + "func Handler() {}\n\n" + "func privateHelper() {}\n\n" + "func anotherPrivate() {}\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 90.0 + + def test_go_doc_go_detected(self, tmp_path): + """doc.go files should be detected in evidence.""" + repo = _make_go_repo(tmp_path) + + doc_file = tmp_path / "doc.go" + doc_file.write_text( + "// Package handler provides HTTP handling.\npackage handler\n" + ) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "// Handler processes requests.\n" + "func Handler() {}\n" + ) + _git_add(tmp_path, go_file, doc_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert any("doc.go" in e for e in finding.evidence) + + def test_go_test_files_excluded(self, tmp_path): + """Test files (*_test.go) should not count toward documentation coverage.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "// Handler processes requests.\n" + "func Handler() {}\n" + ) + + test_file = tmp_path / "handler_test.go" + test_file.write_text( + "package handler\n\n" "func TestHandler() {}\n" "func TestOther() {}\n" + ) + _git_add(tmp_path, go_file, test_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 90.0 + + def test_go_no_exports(self, tmp_path): + """Go repo with no exported symbols returns not_applicable.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text("package handler\n\n" "func privateFunc() {}\n") + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "not_applicable" + + +# ============================================================================= +# CodeSmellsAssessor — Go (already supported, verify) +# ============================================================================= + + +class TestCodeSmellsAssessorGo: + """Verify existing Go support in CodeSmellsAssessor.""" + + def test_go_with_golangci_lint(self, tmp_path): + """Go repo with .golangci.yml should score for Go linter.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / ".golangci.yml").write_text("linters:\n enable:\n - errcheck\n") + + assessor = CodeSmellsAssessor() + finding = assessor.assess(repo) + + assert any("golangci-lint" in e for e in finding.evidence) + + def test_go_with_golangci_toml(self, tmp_path): + """Go repo with .golangci.toml should also be detected.""" + repo = _make_go_repo(tmp_path) + + (tmp_path / ".golangci.toml").write_text('[linters]\nenable = ["errcheck"]\n') + + assessor = CodeSmellsAssessor() + finding = assessor.assess(repo) + + assert any("golangci-lint" in e for e in finding.evidence) + + +# ============================================================================= +# CIQualityGatesAssessor — Go (already supported, verify) +# ============================================================================= + + +class TestCIQualityGatesAssessorGo: + """Verify Go CI patterns are detected.""" + + def test_go_test_in_ci(self, tmp_path): + """CI config with 'go test' should be detected as a test gate.""" + repo = _make_go_repo(tmp_path) + + workflows_dir = tmp_path / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + ci_file = workflows_dir / "ci.yml" + ci_file.write_text( + "name: CI\n" + "on: [push]\n" + "jobs:\n" + " test:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + " - run: go test ./...\n" + " - run: golangci-lint run\n" + " - run: go vet ./...\n" + ) + + assessor = CIQualityGatesAssessor() + finding = assessor.assess(repo) + + assert any("Test gate" in e for e in finding.evidence) + assert any("Lint gate" in e for e in finding.evidence) + assert any("Type-check gate" in e for e in finding.evidence) + + +# ============================================================================= +# Regression tests for PR #412 review findings +# ============================================================================= + + +class TestGoMultiLineGodoc: + """Tests for multi-line godoc comment detection.""" + + def test_go_multiline_doc_comment(self, tmp_path): + """Multi-line // doc comments should be detected.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "// Handler processes incoming HTTP requests.\n" + "// It routes them to the appropriate service method.\n" + "func Handler() {}\n\n" + "// Config holds the application configuration.\n" + "// It is loaded from environment variables.\n" + "type Config struct {}\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 90.0 + + def test_go_block_comment_doc(self, tmp_path): + """/* */ block-style doc comments should be detected.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "/* Handler processes incoming HTTP requests. */\n" + "func Handler() {}\n\n" + "/* Config holds the application configuration.\n" + "It supports multiple environments. */\n" + "type Config struct {}\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 90.0 + + def test_go_method_with_receiver_documented(self, tmp_path): + """Exported methods with receivers should be detected and docs counted.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "server.go" + go_file.write_text( + "package server\n\n" + "// Start begins listening for connections.\n" + "func (s *Server) Start() {}\n\n" + "// Stop gracefully shuts down the server.\n" + "func (s *Server) Stop() {}\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 90.0 + + +class TestGoMakeVariableTest: + """Tests for $(GO) test Makefile convention.""" + + def test_go_make_variable_test_command(self, tmp_path): + """$(GO) test should be recognized as a test command.""" + repo = _make_go_repo(tmp_path) + + test_file = tmp_path / "handler_test.go" + test_file.write_text('package main\nimport "testing"\n') + _git_add(tmp_path, test_file) + + makefile = tmp_path / "Makefile" + makefile.write_text( + "GO ?= go\n\n" + ".PHONY: test\n" + "test:\n" + "\t$(GO) test -v -race -coverprofile=coverage.txt ./...\n" + ) + + assessor = TestExecutionAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert any("test command found" in e.lower() for e in finding.evidence) + + +class TestGoAnyInComments: + """Tests for any/interface{} matching excluding comments.""" + + def test_go_any_in_comments_not_counted(self, tmp_path): + """'any' in comments should not inflate the type-weakness count.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package main\n\n" + "// Process handles any incoming request.\n" + "// It can accept any valid payload.\n" + "func Process(data string) string {\n" + '\treturn "ok"\n' + "}\n\n" + "func Transform(input int) int {\n" + "\treturn input * 2\n" + "}\n" + ) + _git_add(tmp_path, go_file) + + assessor = TypeAnnotationsAssessor() + finding = assessor.assess(repo) + + assert finding.score >= 95.0 + assert finding.status == "pass" + + def test_go_any_in_code_still_counted(self, tmp_path): + """'any' in actual code should still be counted.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package main\n\n" + "func Process(data any) any {\n" + "\treturn data\n" + "}\n\n" + "func Other(x any, y any) any {\n" + "\treturn x\n" + "}\n\n" + "func More(a any) any {\n" + "\treturn a\n" + "}\n" + ) + _git_add(tmp_path, go_file) + + assessor = TypeAnnotationsAssessor() + finding = assessor.assess(repo) + + assert finding.score < 95.0 + + def test_go_any_in_string_literals_not_counted(self, tmp_path): + """'any' in string literals should not inflate the type-weakness count.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package main\n\n" + 'import "fmt"\n\n' + "func Process(data string) string {\n" + '\tfmt.Println("can accept any value")\n' + '\treturn "any"\n' + "}\n\n" + "func Transform(input int) int {\n" + "\treturn input * 2\n" + "}\n" + ) + _git_add(tmp_path, go_file) + + assessor = TypeAnnotationsAssessor() + finding = assessor.assess(repo) + + assert finding.score >= 95.0 + assert finding.status == "pass" + + +class TestGoNonSymbolNameDocs: + """Tests for doc comments that don't start with the symbol name.""" + + def test_go_descriptive_comment_accepted(self, tmp_path): + """Comments describing the function without symbol name prefix are valid.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "remote.go" + go_file.write_text( + "package context\n\n" + "// Filter remotes by given hostnames, maintains original order\n" + "func FilterByHosts(hosts []string) []string {\n" + "\treturn hosts\n" + "}\n\n" + "// Returns the first remote matching the given name\n" + "func FindByName(name string) string {\n" + "\treturn name\n" + "}\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 90.0 + + def test_go_blank_line_separates_comment(self, tmp_path): + """A blank line between comment and symbol means no doc comment.""" + repo = _make_go_repo(tmp_path) + + go_file = tmp_path / "handler.go" + go_file.write_text( + "package handler\n\n" + "// This is not a doc comment because of the blank line.\n" + "\n" + "func Handler() {}\n\n" + "func Process() {}\n" + ) + _git_add(tmp_path, go_file) + + assessor = InlineDocumentationAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score < 75.0 diff --git a/tests/unit/test_bootstrap_templates.py b/tests/unit/test_bootstrap_templates.py index 2fbead2c..80561f27 100644 --- a/tests/unit/test_bootstrap_templates.py +++ b/tests/unit/test_bootstrap_templates.py @@ -80,7 +80,7 @@ def test_go_extends_base_tests(self, jinja_env): # Verify Go-specific elements assert "actions/setup-go@v5" in content assert "go-version:" in content - assert "'1.21', '1.22'" in content + assert "'1.23', '1.24'" in content assert "go mod download" in content assert "go test" in content assert "gofmt" in content