diff --git a/docs/attributes.md b/docs/attributes.md index 2d1e9c00..c298a0f1 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -267,6 +267,7 @@ Type annotations give agents reliable information about what a function expects - Generic types from `typing` module used appropriately - Coverage: >80% of functions typed - **Strict mode bonus** (+15 pts): type checker configured in strict mode. Checked configs: `mypy.ini`/`.mypy.ini` (`strict = true` or `disallow_untyped_defs = true`), `setup.cfg` `[mypy]`, `pyproject.toml` `[tool.mypy]`, `pyrightconfig.json` (`typeCheckingMode: "strict"`), `pyproject.toml` `[tool.pyright]` +- 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 271cd508..126cb351 100644 --- a/src/agentready/assessors/code_quality.py +++ b/src/agentready/assessors/code_quality.py @@ -97,11 +97,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 @@ -110,7 +115,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 @@ -119,6 +124,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 @@ -246,6 +253,22 @@ def _check_python_strict_mode( return 0.0, [] + @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 index 4629dd03..eba0f9df 100644 --- a/tests/unit/test_assessors_code_quality.py +++ b/tests/unit/test_assessors_code_quality.py @@ -4,6 +4,8 @@ import subprocess from unittest.mock import patch +import pytest + from agentready.assessors.code_quality import ( CyclomaticComplexityAssessor, TypeAnnotationsAssessor, @@ -422,3 +424,142 @@ def test_radon_exception_returns_error(self, tmp_path): assert finding.status == "error" assert "radon crashed" in finding.error_message + + +# ============================================================================= +# TypeAnnotationsAssessor — Python test file/function skipping (#385) +# ============================================================================= + + +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"