diff --git a/src/agentready/assessors/stub_assessors.py b/src/agentready/assessors/stub_assessors.py index 9d9935d5..9679b007 100644 --- a/src/agentready/assessors/stub_assessors.py +++ b/src/agentready/assessors/stub_assessors.py @@ -4,8 +4,11 @@ enhanced later with more sophisticated detection and scoring logic. """ +import json from pathlib import Path +import yaml + from ..models.attribute import Attribute from ..models.finding import Citation, Finding, Remediation from ..models.repository import Repository @@ -257,7 +260,60 @@ def assess(self, repository: Repository) -> Finding: ) has_husky = (repository.path / ".husky").exists() - if has_commitlint or has_husky: + # Check for commitlint config in package.json (common in Node.js projects) + package_json = repository.path / "package.json" + has_package_commitlint = False + if package_json.exists(): + try: + package_data = json.loads(package_json.read_text()) + has_package_commitlint = "commitlint" in package_data + except (json.JSONDecodeError, OSError): + pass + + precommit_config = repository.path / ".pre-commit-config.yaml" + # Check for conventional commits in pre-commit config + has_precommit_conventional = False + + if precommit_config.exists(): + try: + content = precommit_config.read_text() + + # Note: gitlint and committed are generic linters that don't enforce + # conventional commits by default. So they are not being added. + conventional_patterns = { + "conventional-precommit-linter", + "conventional-pre-commit", + "commitlint-pre-commit-hook", + } + + # Parse YAML to check repo URLs (avoids false positives from comments) + try: + data = yaml.safe_load(content) + repos = data.get("repos", []) if isinstance(data, dict) else [] + + for repo in repos: + if not isinstance(repo, dict): + continue + repo_url = repo.get("repo", "") + if any( + pattern in repo_url for pattern in conventional_patterns + ): + has_precommit_conventional = True + break + + except yaml.YAMLError: + # Return False on YAML parse failure - malformed configs shouldn't count as "configured" + # Avoids false positives from string matching against comments or invalid syntax + has_precommit_conventional = False + except OSError: + pass + + if ( + has_commitlint + or has_husky + or has_package_commitlint + or has_precommit_conventional + ): return Finding( attribute=self.attribute, status="pass", @@ -275,13 +331,26 @@ def assess(self, repository: Repository) -> Finding: score=0.0, measured_value="not configured", threshold="configured", - evidence=["No commitlint or husky configuration"], + evidence=[ + "No commitlint configuration found (.commitlintrc.json, package.json, husky, or pre-commit)" + ], remediation=Remediation( summary="Configure conventional commits with commitlint", - steps=["Install commitlint", "Configure husky for commit-msg hook"], - tools=["commitlint", "husky"], + steps=[ + "Option A (Python/pre-commit): Add conventional-pre-commit to .pre-commit-config.yaml", + "Option B (JS/commitlint): Install commitlint and configure husky for commit-msg hook", + ], + tools=[ + "pre-commit", + "conventional-pre-commit", + "commitlint", + "husky", + ], commands=[ - "npm install --save-dev @commitlint/cli @commitlint/config-conventional husky" + "# Python (pre-commit):", + "pip install pre-commit && pre-commit install --hook-type commit-msg", + "# JS (commitlint + husky):", + "npm install --save-dev @commitlint/cli @commitlint/config-conventional husky", ], examples=[], citations=[], diff --git a/tests/unit/test_assessors_stub.py b/tests/unit/test_assessors_stub.py index 938a15fb..2854f7da 100644 --- a/tests/unit/test_assessors_stub.py +++ b/tests/unit/test_assessors_stub.py @@ -1,5 +1,6 @@ """Tests for stub assessors (enhanced implementations).""" +import os import subprocess import pytest @@ -746,3 +747,441 @@ def test_fails_with_no_config(self, tmp_path): assert finding.score == 0.0 assert finding.measured_value == "not configured" assert finding.remediation is not None + + def test_no_configuration_files(self, tmp_path): + """Test that assessor fails when no conventional commit tools are configured.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 0.0 + assert "not configured" in finding.measured_value + assert ( + "No commitlint configuration found (.commitlintrc.json, package.json, husky, or pre-commit)" + in finding.evidence + ) + assert finding.remediation is not None + + def test_commitlint_configuration(self, tmp_path): + """Test detection of commitlint configuration.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .commitlintrc.json + commitlint_config = tmp_path / ".commitlintrc.json" + commitlint_config.write_text('{"extends": ["@commitlint/config-conventional"]}') + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"JavaScript": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert "configured" in finding.measured_value + assert "Commit linting configured" in finding.evidence + + def test_husky_configuration(self, tmp_path): + """Test detection of husky configuration.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .husky directory + husky_dir = tmp_path / ".husky" + husky_dir.mkdir() + commit_msg_hook = husky_dir / "commit-msg" + commit_msg_hook.write_text("#!/bin/sh\nnpx --no -- commitlint --edit $1") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"JavaScript": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert "configured" in finding.measured_value + + def test_package_json_commitlint_configuration(self, tmp_path): + """Test detection of commitlint configuration in package.json.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create package.json with commitlint config + package_json = tmp_path / "package.json" + package_json.write_text("""{ + "name": "test-project", + "commitlint": { + "extends": ["@commitlint/config-conventional"] + }, + "devDependencies": { + "@commitlint/cli": "^17.0.0", + "@commitlint/config-conventional": "^17.0.0" + } +}""") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"JavaScript": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert "configured" in finding.measured_value + + def test_package_json_malformed(self, tmp_path): + """Test handling of malformed package.json.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create malformed package.json + package_json = tmp_path / "package.json" + package_json.write_text("{ invalid json content") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"JavaScript": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + # Should fail gracefully and not crash + assert finding.status == "fail" + assert finding.score == 0.0 + + def test_package_json_no_commitlint(self, tmp_path): + """Test package.json without commitlint configuration.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create package.json without commitlint config + package_json = tmp_path / "package.json" + package_json.write_text("""{ + "name": "test-project", + "devDependencies": { + "eslint": "^8.0.0" + } +}""") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"JavaScript": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 0.0 + + def test_precommit_conventional_linter(self, tmp_path): + """Test detection of conventional-precommit-linter in pre-commit config.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .pre-commit-config.yaml with conventional-precommit-linter + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("""repos: + - repo: https://github.com/compilerla/conventional-precommit-linter + rev: v2.1.1 + hooks: + - id: conventional-precommit-linter + stages: [commit-msg] + args: [feat, fix, docs, style, refactor, test, chore] +""") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert "configured" in finding.measured_value + assert "Commit linting configured" in finding.evidence + + def test_precommit_conventional_pre_commit(self, tmp_path): + """Test detection of conventional-pre-commit in pre-commit config.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .pre-commit-config.yaml with conventional-pre-commit + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("""repos: + - repo: https://github.com/example/conventional-pre-commit + rev: v1.0.0 + hooks: + - id: conventional-commit +""") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + + def test_precommit_commitlint_hook(self, tmp_path): + """Test detection of commitlint-pre-commit-hook in pre-commit config.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .pre-commit-config.yaml with commitlint-pre-commit-hook + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("""repos: + - repo: https://github.com/example/commitlint-pre-commit-hook + rev: v1.0.0 + hooks: + - id: commitlint +""") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + + def test_precommit_no_conventional_tools(self, tmp_path): + """Test that pre-commit config without conventional tools fails.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .pre-commit-config.yaml without conventional commit tools + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("""repos: + - repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort +""") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 0.0 + assert "not configured" in finding.measured_value + + def test_precommit_empty_config(self, tmp_path): + """Test that empty pre-commit config fails.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create empty .pre-commit-config.yaml + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 0.0 + + def test_precommit_invalid_yaml_fallback(self, tmp_path): + """Test that invalid YAML in pre-commit config fails gracefully without attempting string matching fallback""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .pre-commit-config.yaml with invalid YAML (tests error handling) + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("invalid: yaml: content: [") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + # Should fail gracefully and not crash + assert finding.status == "fail" + assert finding.score == 0.0 + + def test_multiple_tools_configured(self, tmp_path): + """Test repository with both commitlint and pre-commit conventional tools.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .commitlintrc.json + commitlint_config = tmp_path / ".commitlintrc.json" + commitlint_config.write_text('{"extends": ["@commitlint/config-conventional"]}') + + # Create .pre-commit-config.yaml with conventional tools + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("""repos: + - repo: https://github.com/compilerla/conventional-precommit-linter + rev: v2.1.1 + hooks: + - id: conventional-precommit-linter + stages: [commit-msg] +""") + + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 50, "JavaScript": 50}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100.0 + assert "configured" in finding.measured_value + + @pytest.mark.skipif(os.getuid() == 0, reason="chmod has no effect as root") + def test_precommit_file_permission_error(self, tmp_path): + """Test handling of permission error when reading pre-commit config.""" + # Initialize git repository + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + + # Create .pre-commit-config.yaml + precommit_config = tmp_path / ".pre-commit-config.yaml" + precommit_config.write_text("repos: []") + + # Make file unreadable (simulate permission error) + os.chmod(precommit_config, 0o000) + + try: + repo = Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages={"Python": 100}, + total_files=10, + total_lines=100, + ) + + assessor = ConventionalCommitsAssessor() + finding = assessor.assess(repo) + + # Should handle the exception gracefully + assert finding.status == "fail" + assert finding.score == 0.0 + + finally: + # Restore permissions for cleanup + os.chmod(precommit_config, 0o644)