diff --git a/docs/attributes.md b/docs/attributes.md index c321365c..5faaeabc 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -266,6 +266,7 @@ Type annotations give agents reliable information about what a function expects - All public functions have parameter and return type hints - Generic types from `typing` module used appropriately - Coverage: >80% of functions typed +- Test files and test functions (`test_*`) are excluded from scoring - Tools: mypy, pyright **TypeScript**: diff --git a/src/agentready/assessors/code_quality.py b/src/agentready/assessors/code_quality.py index 051a94c4..b5060db1 100644 --- a/src/agentready/assessors/code_quality.py +++ b/src/agentready/assessors/code_quality.py @@ -85,11 +85,16 @@ def _assess_python_types(self, repository: Repository) -> Finding: timeout=30, check=True, ) - python_files = [f for f in result.stdout.strip().split("\n") if f] + python_files = [ + f + for f in result.stdout.strip().split("\n") + if f and not self._is_python_test_file(f) + ] except Exception: python_files = [ str(f.relative_to(repository.path)) for f in repository.path.rglob("*.py") + if not self._is_python_test_file(str(f.relative_to(repository.path))) ] total_functions = 0 @@ -98,7 +103,7 @@ def _assess_python_types(self, repository: Repository) -> Finding: for file_path in python_files: full_path = repository.path / file_path try: - with open(full_path, "r", encoding="utf-8") as f: + with open(full_path, encoding="utf-8") as f: content = f.read() # Parse the file with AST @@ -107,6 +112,8 @@ def _assess_python_types(self, repository: Repository) -> Finding: # Walk the AST and count functions with type annotations for node in ast.walk(tree): if isinstance(node, ast.FunctionDef): + if node.name.startswith("test_"): + continue total_functions += 1 # Check if function has type annotations # Return type annotation: node.returns is not None @@ -152,6 +159,22 @@ def _assess_python_types(self, repository: Repository) -> Finding: error_message=None, ) + @staticmethod + def _is_python_test_file(file_path: str) -> bool: + """Check if a Python file is a test file based on path and name conventions.""" + from pathlib import PurePosixPath + + normalized = file_path.replace("\\", "/") + parts = PurePosixPath(normalized).parts + name = parts[-1] if parts else "" + if ( + name.startswith("test_") + or name.endswith("_test.py") + or name == "conftest.py" + ): + return True + return any(p in ("tests", "test") for p in parts) + def _assess_typescript_types(self, repository: Repository) -> Finding: """Assess TypeScript type configuration across all tsconfig.json files. diff --git a/tests/unit/test_assessors_code_quality.py b/tests/unit/test_assessors_code_quality.py new file mode 100644 index 00000000..b7930223 --- /dev/null +++ b/tests/unit/test_assessors_code_quality.py @@ -0,0 +1,142 @@ +"""Tests for TypeAnnotationsAssessor — Python test file/function skipping (#385).""" + +import subprocess + +import pytest + +from agentready.assessors.code_quality import TypeAnnotationsAssessor +from agentready.models.repository import Repository + + +def _make_py_repo(tmp_path, languages=None): + """Create a test Python repository with git init.""" + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + return Repository( + path=tmp_path, + name="test-py-repo", + url=None, + branch="main", + commit_hash="abc123", + languages=languages or {"Python": 20}, + total_files=30, + 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, + ) + + +class TestIsTestFile: + """Unit tests for _is_python_test_file static method.""" + + @pytest.mark.parametrize( + "path", + [ + "tests/test_foo.py", + "test/test_bar.py", + "tests/unit/test_baz.py", + "src/tests/test_helpers.py", + "test_something.py", + "foo_test.py", + "conftest.py", + "tests/conftest.py", + "tests\\test_foo.py", + "test\\test_bar.py", + ], + ) + def test_identifies_test_files(self, path): + assert TypeAnnotationsAssessor._is_python_test_file(path) is True + + @pytest.mark.parametrize( + "path", + [ + "src/app.py", + "src/utils/helpers.py", + "main.py", + "lib/testing_utils.py", + ], + ) + def test_identifies_non_test_files(self, path): + assert TypeAnnotationsAssessor._is_python_test_file(path) is False + + +class TestPythonTypeAnnotationsSkipTests: + """Integration tests: test files and test functions are excluded from scoring.""" + + def test_test_files_excluded_from_scoring(self, tmp_path): + """Test files in tests/ should not affect type annotation scoring.""" + repo = _make_py_repo(tmp_path) + + # Source file: fully typed + src = tmp_path / "src" + src.mkdir() + (src / "app.py").write_text("def greet(name: str) -> str:\n return name\n") + + # Test file: untyped (should be excluded) + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_app.py").write_text("def test_greet():\n assert True\n") + + _git_add(tmp_path, src / "app.py", tests / "test_app.py") + + assessor = TypeAnnotationsAssessor() + finding = assessor._assess_python_types(repo) + + assert finding.status == "pass" + assert "1/1" in finding.evidence[0] + + def test_test_functions_excluded_in_source_files(self, tmp_path): + """Test functions (test_*) inside source files should be excluded.""" + repo = _make_py_repo(tmp_path) + + (tmp_path / "app.py").write_text( + "def greet(name: str) -> str:\n" + " return name\n" + "\n" + "def test_greet():\n" + " assert greet('hi') == 'hi'\n" + ) + + _git_add(tmp_path, tmp_path / "app.py") + + assessor = TypeAnnotationsAssessor() + finding = assessor._assess_python_types(repo) + + assert finding.status == "pass" + assert "1/1" in finding.evidence[0] + + def test_untyped_source_still_fails(self, tmp_path): + """Non-test source without annotations should still fail.""" + repo = _make_py_repo(tmp_path) + + (tmp_path / "app.py").write_text("def greet(name):\n return name\n") + + _git_add(tmp_path, tmp_path / "app.py") + + assessor = TypeAnnotationsAssessor() + finding = assessor._assess_python_types(repo) + + assert finding.status == "fail" + + def test_only_test_files_returns_not_applicable(self, tmp_path): + """Repo with only test files should return not_applicable.""" + repo = _make_py_repo(tmp_path) + + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_foo.py").write_text("def test_foo():\n assert True\n") + + _git_add(tmp_path, tests / "test_foo.py") + + assessor = TypeAnnotationsAssessor() + finding = assessor._assess_python_types(repo) + + assert finding.status == "not_applicable"