diff --git a/.coverage b/.coverage deleted file mode 100644 index 3350178..0000000 Binary files a/.coverage and /dev/null differ diff --git a/src/analyzers/code_quality.py b/src/analyzers/code_quality.py index 9ffb09a..37b01be 100644 --- a/src/analyzers/code_quality.py +++ b/src/analyzers/code_quality.py @@ -299,7 +299,7 @@ def _classify_commits(messages: list[str]) -> dict: for msg in messages: if CONVENTIONAL_PATTERN.match(msg): conventional_count += 1 - type_ = msg.split(":")[0].split("(")[0].strip() + type_ = msg.split(":")[0].split("(")[0].strip().removesuffix("!") types[type_] += 1 if re.search(r"#\d+", msg): issue_refs += 1 diff --git a/tests/test_activity.py b/tests/test_activity.py new file mode 100644 index 0000000..cf6c7ee --- /dev/null +++ b/tests/test_activity.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from src.analyzers.activity import ( + ActivityAnalyzer, + _classify_commit_pattern, + _compute_bus_factor, + _count_clusters, + _recent_commit_count, +) +from src.models import RepoMetadata + + +def _make_metadata(**kwargs) -> RepoMetadata: + defaults = dict( + name="test-repo", + full_name="user/test-repo", + description="A test repo", + language="Python", + languages={"Python": 5000}, + private=False, + fork=False, + archived=False, + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 3, 1, tzinfo=timezone.utc), + pushed_at=datetime.now(timezone.utc) - timedelta(days=30), + default_branch="main", + stars=5, + forks=1, + open_issues=0, + size_kb=512, + html_url="https://github.com/user/test-repo", + clone_url="https://github.com/user/test-repo.git", + topics=[], + ) + defaults.update(kwargs) + return RepoMetadata(**defaults) + + +def _make_client( + *, + contributor_stats: list[dict] | None = None, + commit_activity: list[dict] | None = None, + releases: list[dict] | None = None, + releases_available: bool = True, +) -> MagicMock: + client = MagicMock() + client.get_contributor_stats.return_value = contributor_stats or [] + client.get_commit_activity.return_value = commit_activity or [] + client.get_releases.return_value = (releases or [], releases_available) + return client + + +def _weeks(totals: list[int]) -> list[dict]: + return [{"total": total} for total in totals] + + +def _release( + tag: str, + published_at: object | None = "2026-03-01T00:00:00Z", + *, + created_at: object | None = None, + prerelease: bool = False, +) -> dict: + return { + "tag_name": tag, + "name": tag, + "published_at": published_at, + "created_at": published_at if created_at is None else created_at, + "prerelease": prerelease, + "draft": False, + } + + +class TestActivityAnalyze: + def test_within_one_year_push_scores_recent_branch(self, tmp_path: Path) -> None: + metadata = _make_metadata( + pushed_at=datetime.now(timezone.utc) - timedelta(days=240), + ) + + result = ActivityAnalyzer().analyze(tmp_path, metadata, github_client=None) + + assert result.score == pytest.approx(0.3) + assert result.details["days_since_push"] >= 239 + assert "Recent: pushed" in result.findings[0] + assert "Skipped API-based activity checks" in result.findings + + def test_stale_push_and_archived_repo_do_not_add_activity_points( + self, tmp_path: Path + ) -> None: + metadata = _make_metadata( + archived=True, + pushed_at=datetime.now(timezone.utc) - timedelta(days=500), + ) + + result = ActivityAnalyzer().analyze(tmp_path, metadata, github_client=None) + + assert result.score == 0.0 + assert result.details["archived"] is True + assert "Stale: last push" in result.findings[0] + assert "Repo is archived" in result.findings + + def test_missing_push_date_keeps_days_since_push_absent( + self, tmp_path: Path + ) -> None: + metadata = _make_metadata(pushed_at=None) + + result = ActivityAnalyzer().analyze(tmp_path, metadata, github_client=None) + + assert result.score == pytest.approx(0.1) + assert "days_since_push" not in result.details + assert result.findings[0] == "No push date available" + assert "Not archived" in result.findings + + def test_api_checks_score_total_commits_recent_commits_and_bus_factor( + self, tmp_path: Path + ) -> None: + commit_activity = _weeks([0] * 39 + [1] * 13) + client = _make_client( + contributor_stats=[{"total": 8}, {"total": 4}], + commit_activity=commit_activity, + releases=[], + ) + + result = ActivityAnalyzer().analyze( + tmp_path, + _make_metadata(), + github_client=client, + ) + + assert result.score == pytest.approx(0.8) + assert result.details["total_commits"] == 12 + assert result.details["recent_3mo_commits"] == 13 + assert result.details["commit_pattern"] == "new" + assert result.details["bus_factor"] == 1 + assert result.details["has_any_release"] is False + assert "Total commits: 12" in result.findings + assert "Recent commits (3mo): 13" in result.findings + assert "No releases found" in result.findings + + def test_api_checks_report_few_commits_no_recent_commits_and_unavailable_releases( + self, tmp_path: Path + ) -> None: + client = _make_client( + contributor_stats=[{"total": 5}], + commit_activity=[], + releases=[], + releases_available=False, + ) + + result = ActivityAnalyzer().analyze( + tmp_path, + _make_metadata(), + github_client=client, + ) + + assert result.score == pytest.approx(0.5) + assert result.details["total_commits"] == 5 + assert result.details["recent_3mo_commits"] == 0 + assert result.details["commit_pattern"] == "unknown" + assert result.details["bus_factor"] == 1 + assert result.details["releases_available"] is False + assert "Few commits: 5" in result.findings + assert "No commits in last 3 months" in result.findings + assert "Releases endpoint unavailable (404)" in result.findings + + def test_api_checks_report_zero_commit_count(self, tmp_path: Path) -> None: + client = _make_client( + contributor_stats=[], + commit_activity=[], + releases=[], + ) + + result = ActivityAnalyzer().analyze( + tmp_path, + _make_metadata(), + github_client=client, + ) + + assert result.score == pytest.approx(0.4) + assert result.details["total_commits"] == 0 + assert result.details["bus_factor"] == 0 + assert "Zero or unknown commit count" in result.findings + + @pytest.mark.parametrize("malformed", ["not-a-date", b"not-a-date"]) + def test_malformed_latest_release_timestamp_sets_age_to_none( + self, tmp_path: Path, malformed: object + ) -> None: + client = _make_client( + contributor_stats=[{"total": 11}], + commit_activity=_weeks([0] * 39 + [1] * 13), + releases=[ + _release("v2.0", malformed), + _release("v1.0", "2026-01-01T00:00:00Z"), + ], + ) + + result = ActivityAnalyzer().analyze( + tmp_path, + _make_metadata(), + github_client=client, + ) + + assert result.details["latest_release_age_days"] is None + assert result.details["release_count"] == 2 + assert "release_cadence_days" not in result.details + assert "Releases: 2" in result.findings + + def test_release_without_timestamp_sets_age_to_none(self, tmp_path: Path) -> None: + client = _make_client( + contributor_stats=[{"total": 11}], + commit_activity=_weeks([0] * 39 + [1] * 13), + releases=[_release("v1.0", "", created_at="")], + ) + + result = ActivityAnalyzer().analyze( + tmp_path, + _make_metadata(), + github_client=client, + ) + + assert result.details["latest_release_age_days"] is None + assert result.details["latest_release_is_prerelease"] is False + assert result.details["release_count"] == 1 + + def test_valid_release_dates_compute_rounded_cadence(self, tmp_path: Path) -> None: + client = _make_client( + contributor_stats=[{"total": 11}], + commit_activity=_weeks([0] * 39 + [1] * 13), + releases=[ + _release("v3.0", "2026-03-01T00:00:00Z", prerelease=True), + _release("v2.0", "2026-01-31T00:00:00Z"), + _release("v1.0", "2026-01-01T00:00:00Z"), + ], + ) + + result = ActivityAnalyzer().analyze( + tmp_path, + _make_metadata(), + github_client=client, + ) + + assert result.details["release_cadence_days"] == 30 + assert result.details["latest_release_is_prerelease"] is True + assert result.details["has_any_release"] is True + + +class TestActivityHelpers: + def test_recent_commit_count_sums_only_last_thirteen_weeks(self) -> None: + activity = _weeks([100] * 39 + list(range(13))) + + assert _recent_commit_count(activity) == 78 + assert _recent_commit_count([]) == 0 + + @pytest.mark.parametrize( + ("totals", "expected"), + [ + ([], "unknown"), + ([0] * 52, "dormant"), + ([2] + [0] * 51, "dormant"), + ([0] * 39 + [1] * 13, "new"), + ([1] * 20 + [0] * 6 + [1] + [0] * 25, "steady"), + ([10] * 9 + [0] * 30 + [5] + [0] * 12, "winding-down"), + ([0] * 20 + [10] * 3 + [0] * 16 + [10] * 4 + [0] * 9, "burst"), + ([0] * 10 + [1] * 6 + [0] * 24 + [1] * 6 + [0] * 6, "seasonal"), + ([0] * 30 + [1] * 12 + [0] * 10, "burst"), + ], + ) + def test_classify_commit_pattern_branches( + self, totals: list[int], expected: str + ) -> None: + assert _classify_commit_pattern(_weeks(totals)) == expected + + def test_count_clusters_resets_only_after_minimum_gap(self) -> None: + assert _count_clusters([1, 1, 0, 0, 0, 0, 2]) == 2 + assert _count_clusters([1, 0, 0, 0, 2]) == 1 + + @pytest.mark.parametrize( + ("stats", "expected"), + [ + ([], 0), + ([{"total": 0}, {"total": 0}], 0), + ([{"total": 80}, {"total": 10}, {"total": 10}], 1), + ([{"total": 25}, {"total": 25}, {"total": 25}, {"total": 25}], 2), + ], + ) + def test_compute_bus_factor_branches( + self, stats: list[dict], expected: int + ) -> None: + assert _compute_bus_factor(stats) == expected diff --git a/tests/test_analyzer_cache.py b/tests/test_analyzer_cache.py index ac90378..3d14146 100644 --- a/tests/test_analyzer_cache.py +++ b/tests/test_analyzer_cache.py @@ -3,10 +3,11 @@ from __future__ import annotations import sqlite3 +from dataclasses import asdict from datetime import datetime, timezone from pathlib import Path -from src.analyzer_cache import invalidate_repo, lookup, stats, store +from src.analyzer_cache import _deep_equal, _diff_summary, invalidate_repo, lookup, reconcile, stats, store from src.analyzers import ALL_ANALYZERS, run_all_analyzers, run_with_cache from src.analyzers.dependencies import DependenciesAnalyzer from src.analyzers.readme import ReadmeAnalyzer @@ -49,6 +50,17 @@ def _sample_metadata(name: str = "my-repo") -> RepoMetadata: ) +class _FakeAnalyzer: + def __init__(self, name: str, inputs_hash: str | Exception): + self.name = name + self._inputs_hash = inputs_hash + + def cache_inputs_hash(self, repo_path, metadata): + if isinstance(self._inputs_hash, Exception): + raise self._inputs_hash + return self._inputs_hash + + # ── lookup / store round-trip ────────────────────────────────────────── @@ -148,6 +160,168 @@ def test_store_replaces_existing_entry(self): assert hit["score"] == 0.9 assert hit["findings"] == ["v2"] + def test_lookup_returns_none_when_cached_json_is_invalid(self): + conn = _fresh_db() + conn.execute( + """ + INSERT INTO analyzer_cache + (repo_name, commit_sha, analyzer_name, inputs_hash, result_json, computed_at, schema_version) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "repo", + "abc123", + "readme", + "hashval", + "{not valid json", + datetime.now(timezone.utc).isoformat(), + 1, + ), + ) + conn.commit() + + assert lookup(conn, "repo", "abc123", "readme", "hashval") is None + + def test_store_swallows_non_serializable_payload(self): + conn = _fresh_db() + store(conn, "repo", "abc123", "readme", "hashval", {"bad": {object()}}) + + assert lookup(conn, "repo", "abc123", "readme", "hashval") is None + + +# ── reconcile diff helpers ──────────────────────────────────────────── + + +class TestReconcileDiffHelpers: + def test_deep_equal_rejects_different_non_numeric_types(self): + assert _deep_equal("a", ["a"]) is False + + def test_deep_equal_rejects_lists_with_different_lengths(self): + assert _deep_equal([1, 2], [1, 2, 3]) is False + + def test_diff_summary_marks_added_and_removed_keys(self): + summary = _diff_summary({"oldkey": 1, "shared": 2}, {"newkey": 1, "shared": 2}) + + assert "+newkey" in summary + assert "-oldkey" in summary + + +# ── reconcile edge behavior ─────────────────────────────────────────── + + +class TestReconcileEdges: + def test_skips_repo_without_metadata(self, tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + conn = _fresh_db() + + def _fresh(path, metadata, conn=None): + raise AssertionError("reconcile should skip repos with no metadata") + + report = reconcile({"repo": repo}, {}, conn, _fresh) + + assert report == { + "checked": 0, + "matched": 0, + "divergent": [], + "missing_from_cache": [], + "ok": True, + } + + def test_uses_pushed_at_as_sha_when_commit_map_is_missing(self, tmp_path: Path, monkeypatch): + repo = tmp_path / "repo" + repo.mkdir() + meta = _sample_metadata("repo") + conn = _fresh_db() + analyzer = _FakeAnalyzer("edge", "hashval") + result = AnalyzerResult("edge", 1.0, 1.0, ["ok"], {"source": "fresh"}) + store(conn, "repo", meta.pushed_at.isoformat(), "edge", "hashval", asdict(result)) + + def _fresh(path, metadata, conn=None): + return [result] + + monkeypatch.setattr("src.analyzers.ALL_ANALYZERS", [analyzer]) + report = reconcile({"repo": repo}, {"repo": meta}, conn, _fresh) + + assert report["checked"] == 1 + assert report["matched"] == 1 + assert report["divergent"] == [] + assert report["missing_from_cache"] == [] + assert report["ok"] is True + + def test_skips_repo_when_sha_cannot_be_derived(self, tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + meta = RepoMetadata(**{**vars(_sample_metadata("repo")), "pushed_at": None}) + conn = _fresh_db() + + def _fresh(path, metadata, conn=None): + raise AssertionError("reconcile should skip repos with no stable sha") + + report = reconcile({"repo": repo}, {"repo": meta}, conn, _fresh) + + assert report["checked"] == 0 + assert report["matched"] == 0 + assert report["divergent"] == [] + assert report["missing_from_cache"] == [] + assert report["ok"] is True + + def test_fresh_run_exception_skips_repo_and_continues(self, tmp_path: Path, monkeypatch): + bad_repo = tmp_path / "bad" + good_repo = tmp_path / "good" + bad_repo.mkdir() + good_repo.mkdir() + bad_meta = _sample_metadata("bad") + good_meta = _sample_metadata("good") + conn = _fresh_db() + analyzer = _FakeAnalyzer("edge", "hashval") + result = AnalyzerResult("edge", 0.7, 1.0, ["fresh"], {}) + + def _fresh(path, metadata, conn=None): + if metadata.name == "bad": + raise RuntimeError("boom") + return [result] + + monkeypatch.setattr("src.analyzers.ALL_ANALYZERS", [analyzer]) + report = reconcile( + {"bad": bad_repo, "good": good_repo}, + {"bad": bad_meta, "good": good_meta}, + conn, + _fresh, + commit_sha_map={"bad": "bad-sha", "good": "good-sha"}, + ) + + assert report["checked"] == 1 + assert report["matched"] == 0 + assert report["divergent"] == [] + assert report["missing_from_cache"] == [{"repo": "good", "analyzer": "edge"}] + assert report["ok"] is True + + def test_cache_inputs_hash_exception_skips_analyzer_pair(self, tmp_path: Path, monkeypatch): + repo = tmp_path / "repo" + repo.mkdir() + meta = _sample_metadata("repo") + conn = _fresh_db() + analyzer = _FakeAnalyzer("edge", RuntimeError("hash failed")) + + def _fresh(path, metadata, conn=None): + return [AnalyzerResult("edge", 0.5, 1.0, ["fresh"], {})] + + monkeypatch.setattr("src.analyzers.ALL_ANALYZERS", [analyzer]) + report = reconcile( + {"repo": repo}, + {"repo": meta}, + conn, + _fresh, + commit_sha_map={"repo": "sha"}, + ) + + assert report["checked"] == 0 + assert report["matched"] == 0 + assert report["divergent"] == [] + assert report["missing_from_cache"] == [] + assert report["ok"] is True + # ── invalidate_repo ──────────────────────────────────────────────────── diff --git a/tests/test_cicd.py b/tests/test_cicd.py new file mode 100644 index 0000000..a279b7a --- /dev/null +++ b/tests/test_cicd.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import pytest + +from src.analyzers.cicd import CicdAnalyzer, _has_build_scripts + + +class TestCicdAnalyzerAltCi: + def test_github_actions_alt_ci_and_package_build_score_together( + self, tmp_path, sample_metadata + ): + repo = tmp_path / "full-ci-repo" + repo.mkdir() + workflows = repo / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "ci.yaml").write_text("name: CI\non: push\n") + (repo / "Dockerfile").write_text("FROM python:3.12\n") + (repo / "package.json").write_text('{"scripts": {"build": "vite build"}}\n') + + result = CicdAnalyzer().analyze(repo, sample_metadata) + + assert result.score == pytest.approx(1.0) + assert "GitHub Actions: ci.yaml" in result.findings + assert "Alternative CI: Dockerfile" in result.findings + assert "Has build/test scripts" in result.findings + assert result.details["github_actions"] == ["ci.yaml"] + assert result.details["alt_ci"] == ["Dockerfile"] + + def test_dockerfile_scores_alt_ci_only(self, tmp_path, sample_metadata): + repo = tmp_path / "docker-repo" + repo.mkdir() + (repo / "Dockerfile").write_text("FROM python:3.12\n") + + result = CicdAnalyzer().analyze(repo, sample_metadata) + + assert result.score == pytest.approx(0.3) + assert "Alternative CI: Dockerfile" in result.findings + assert "No GitHub Actions workflows" in result.findings + assert "No build scripts detected" in result.findings + assert result.details["alt_ci"] == ["Dockerfile"] + + def test_circleci_directory_scores_alt_ci_only(self, tmp_path, sample_metadata): + repo = tmp_path / "circleci-repo" + repo.mkdir() + (repo / ".circleci").mkdir() + + result = CicdAnalyzer().analyze(repo, sample_metadata) + + assert result.score == pytest.approx(0.3) + assert "Alternative CI: .circleci" in result.findings + assert "No GitHub Actions workflows" in result.findings + assert "No build scripts detected" in result.findings + assert result.details["alt_ci"] == [".circleci"] + + +class TestHasBuildScripts: + def test_package_json_build_script_is_detected(self, tmp_path): + repo = tmp_path / "package-build-repo" + repo.mkdir() + (repo / "package.json").write_text('{"scripts": {"build": "vite build"}}\n') + + assert _has_build_scripts(repo) is True + + def test_package_json_test_script_is_detected(self, tmp_path): + repo = tmp_path / "package-test-repo" + repo.mkdir() + (repo / "package.json").write_text('{"scripts": {"test": "vitest"}}\n') + + assert _has_build_scripts(repo) is True + + def test_malformed_package_json_falls_through_to_false(self, tmp_path): + repo = tmp_path / "malformed-package-repo" + repo.mkdir() + (repo / "package.json").write_text('{"scripts": {"build": "missing brace"\n') + + assert _has_build_scripts(repo) is False + + def test_makefile_is_detected(self, tmp_path): + repo = tmp_path / "makefile-repo" + repo.mkdir() + (repo / "Makefile").write_text("test:\n\tpytest\n") + + assert _has_build_scripts(repo) is True + + @pytest.mark.parametrize("filename", ["justfile", "Justfile"]) + def test_justfile_variants_are_detected(self, tmp_path, filename): + repo = tmp_path / f"{filename}-repo" + repo.mkdir() + (repo / filename).write_text("test:\n pytest\n") + + assert _has_build_scripts(repo) is True diff --git a/tests/test_code_quality.py b/tests/test_code_quality.py new file mode 100644 index 0000000..957bee0 --- /dev/null +++ b/tests/test_code_quality.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import sys +import types +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from src.analyzers.code_quality import ( + CodeQualityAnalyzer, + _classify_commits, + _count_todos, + _detect_vendored, + _find_entry_point, + _has_type_definitions, + _radon_analysis, + _score_commit_messages, +) +from src.models import RepoMetadata + + +def _meta(**overrides) -> RepoMetadata: + defaults = dict( + name="test", + full_name="user/test", + description=None, + language="Python", + languages={"Python": 5000}, + private=False, + fork=False, + archived=False, + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 3, 1, tzinfo=timezone.utc), + pushed_at=datetime(2026, 3, 20, tzinfo=timezone.utc), + default_branch="main", + stars=0, + forks=0, + open_issues=0, + size_kb=100, + html_url="", + clone_url="", + topics=[], + ) + defaults.update(overrides) + return RepoMetadata(**defaults) + + +def _fake_github_client(*, commits: list[dict], prs: list[dict]) -> MagicMock: + client = MagicMock() + client.get_recent_commits.return_value = commits + client.get_pull_requests.return_value = prs + return client + + +def _commit(message: str) -> dict: + return {"commit": {"message": message}} + + +class TestCodeQualityAnalyzer: + def test_analyze_reports_high_todo_density_vendored_content_and_weak_commits( + self, tmp_path: Path + ) -> None: + (tmp_path / "main.py").write_text( + "# TODO: one\n" + "# FIXME: two\n" + "# HACK: three\n" + "print('tiny project')\n" + ) + (tmp_path / "vendor").mkdir() + client = _fake_github_client( + commits=[_commit("fix"), _commit("wip"), _commit("ok enough detail")], + prs=[], + ) + + result = CodeQualityAnalyzer().analyze( + tmp_path, + _meta(), + github_client=client, + ) + + assert result.score == pytest.approx(0.35) + assert result.details["entry_point"] == "main.py" + assert result.details["todo_count"] == 3 + assert result.details["total_loc"] == 4 + assert result.details["todo_density_per_1k"] == 750.0 + assert result.details["has_types"] is False + assert result.details["vendored"] == ["vendor/ committed"] + assert result.details["commit_quality"] == "1/3 descriptive commits" + assert "High TODO density: 750.0/1k LOC" in result.findings + assert "Vendored content: vendor/ committed" in result.findings + assert "Commit messages could be more descriptive" in result.findings + + def test_analyze_reports_moderate_density_conventional_commits_and_pr_ratio( + self, tmp_path: Path + ) -> None: + lines = ["print('line')\n"] * 119 + ["# TODO: later\n"] + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.py").write_text("".join(lines)) + client = _fake_github_client( + commits=[ + _commit("feat: add parser (#12)\n\nBody"), + _commit("fix(api)!: handle retries"), + _commit("docs: refresh usage guide"), + ], + prs=[ + {"merged_at": "2026-03-01T00:00:00Z"}, + {"merged_at": None}, + {"merged_at": "2026-03-02T00:00:00Z"}, + ], + ) + + result = CodeQualityAnalyzer().analyze( + tmp_path, + _meta(), + github_client=client, + ) + + assert result.score == pytest.approx(0.75) + assert result.details["entry_point"] == "src/main.py" + assert result.details["todo_density_per_1k"] == 8.3 + assert result.details["commit_quality"] == "3/3 descriptive commits" + assert result.details["conventional_ratio"] == 1.0 + assert result.details["commit_types"] == {"feat": 1, "fix": 1, "docs": 1} + assert result.details["has_issue_refs"] == 0.33 + assert result.details["pr_total"] == 3 + assert result.details["pr_merged"] == 2 + assert result.details["pr_merge_ratio"] == 0.67 + assert "Moderate TODO density: 8.3/1k LOC" in result.findings + assert "Commit messages are descriptive" in result.findings + assert "Conventional commits: 100%" in result.findings + assert "PRs: 2/3 merged" in result.findings + + def test_analyze_without_code_files_skips_api_commit_analysis( + self, tmp_path: Path + ) -> None: + (tmp_path / "README.md").write_text("# docs only\n") + + result = CodeQualityAnalyzer().analyze(tmp_path, _meta(language="Ruby")) + + assert result.score == pytest.approx(0.15) + assert result.details["entry_point"] is None + assert result.details["todo_count"] == 0 + assert result.details["total_loc"] == 0 + assert result.details["has_types"] is False + assert result.details["vendored"] == [] + assert "No identifiable entry point" in result.findings + assert "No code files to assess TODO density" in result.findings + assert "Skipped commit message analysis (no API client)" in result.findings + + +class TestEntryPointDetection: + def test_detects_generic_entry_point_for_unknown_language(self, tmp_path: Path) -> None: + (tmp_path / "App.tsx").write_text("export default function App() { return null; }\n") + + assert _find_entry_point(tmp_path, "Elixir") == "App.tsx" + + def test_swift_entry_point_skips_derived_data_and_falls_back_to_xcodeproj( + self, tmp_path: Path + ) -> None: + derived = tmp_path / "DerivedData" / "Build" + derived.mkdir(parents=True) + (derived / "MyApp.swift").write_text("@main struct Ignored {}\n") + (tmp_path / "MyApp.xcworkspace").mkdir() + + assert _find_entry_point(tmp_path, "Swift") == "MyApp.xcworkspace" + + def test_swift_entry_point_prefers_app_swift_outside_derived_data( + self, tmp_path: Path + ) -> None: + source = tmp_path / "Sources" / "Mobile" + source.mkdir(parents=True) + (source / "CoolApp.swift").write_text("@main struct CoolApp {}\n") + + assert _find_entry_point(tmp_path, "Swift") == "Sources" + + +class TestTodoCounting: + def test_counts_todo_markers_and_skips_hidden_and_vendored_files( + self, tmp_path: Path + ) -> None: + (tmp_path / "app.py").write_text("# TODO: counted\nprint('x')\n# xxx counted too\n") + hidden = tmp_path / ".cache" + hidden.mkdir() + (hidden / "ignored.py").write_text("# TODO: ignored\n") + vendored = tmp_path / "node_modules" + vendored.mkdir() + (vendored / "ignored.js").write_text("// FIXME: ignored\n") + (tmp_path / "README.md").write_text("TODO in docs is not code\n") + + assert _count_todos(tmp_path) == (2, 3) + + def test_count_todos_stops_after_max_files(self, tmp_path: Path) -> None: + (tmp_path / "a.py").write_text("# TODO: counted\n") + (tmp_path / "b.py").write_text("# TODO: not reached\n") + + assert _count_todos(tmp_path, max_files=1) == (1, 1) + + def test_count_todos_skips_large_and_unreadable_files( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + large = tmp_path / "large.py" + large.write_text("print('large')\n") + unreadable = tmp_path / "unreadable.py" + unreadable.write_text("# TODO: unreadable\n") + normal = tmp_path / "normal.py" + normal.write_text("# FIXME: counted\n") + original_stat = Path.stat + original_read_text = Path.read_text + + def fake_stat(path: Path, *args, **kwargs): + stat = original_stat(path, *args, **kwargs) + if path == large: + return types.SimpleNamespace(st_size=1_000_001, st_mode=stat.st_mode) + return stat + + def fake_read_text(path: Path, *args, **kwargs): + if path == unreadable: + raise OSError("cannot read") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", fake_stat) + monkeypatch.setattr(Path, "read_text", fake_read_text) + + assert _count_todos(tmp_path) == (1, 1) + + +class TestTypeDefinitionDetection: + @pytest.mark.parametrize("language", ["TypeScript", "Rust", "Go", "Java", "Swift", "C#", "Kotlin", "Scala"]) + def test_strongly_typed_languages_count_as_typed( + self, tmp_path: Path, language: str + ) -> None: + assert _has_type_definitions(tmp_path, language) is True + + def test_typescript_file_counts_as_types_outside_node_modules(self, tmp_path: Path) -> None: + (tmp_path / "index.ts").write_text("export const value: number = 1;\n") + node_modules = tmp_path / "node_modules" / "pkg" + node_modules.mkdir(parents=True) + (node_modules / "ignored.ts").write_text("export const ignored = true;\n") + + assert _has_type_definitions(tmp_path, "JavaScript") is True + + def test_python_annotations_count_as_types(self, tmp_path: Path) -> None: + (tmp_path / "module.py").write_text("def answer() -> int:\n return 42\n") + + assert _has_type_definitions(tmp_path, "Python") is True + + def test_python_type_detection_skips_node_modules_and_unreadable_files( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + node_modules = tmp_path / "node_modules" / "pkg" + node_modules.mkdir(parents=True) + (node_modules / "typed.py").write_text("def ignored() -> int:\n return 1\n") + unreadable = tmp_path / "broken.py" + unreadable.write_text("def broken() -> int:\n return 2\n") + original_read_text = Path.read_text + + def fake_read_text(path: Path, *args, **kwargs): + if path == unreadable: + raise OSError("cannot read") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", fake_read_text) + + assert _has_type_definitions(tmp_path, "Python") is False + + +class TestVendoredDetection: + def test_detects_all_vendored_directory_names_and_caps_large_files( + self, tmp_path: Path + ) -> None: + for dirname in ("node_modules", "vendor", "third_party", "bower_components"): + (tmp_path / dirname).mkdir() + for index in range(4): + (tmp_path / f"large-{index}.bin").write_bytes(b"x" * 1_000_001) + + issues = _detect_vendored(tmp_path) + + assert len(issues) == 5 + assert "node_modules/ committed" in issues + assert "vendor/ committed" in issues + assert "third_party/ committed" in issues + assert "bower_components/ committed" in issues + assert any(issue.startswith("Large file:") and issue.endswith("(976KB)") for issue in issues) + + def test_detect_vendored_skips_hidden_files( + self, tmp_path: Path + ) -> None: + hidden = tmp_path / ".hidden" + hidden.mkdir() + (hidden / "large.bin").write_bytes(b"x" * 1_000_001) + + assert _detect_vendored(tmp_path) == [] + + def test_detect_vendored_skips_stat_error_files( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + broken = tmp_path / "broken.bin" + broken.write_text("small\n") + original_is_file = Path.is_file + original_stat = Path.stat + + def fake_is_file(path: Path) -> bool: + if path == broken: + return True + return original_is_file(path) + + def fake_stat(path: Path, *args, **kwargs): + if path == broken: + raise OSError("cannot stat") + return original_stat(path, *args, **kwargs) + + monkeypatch.setattr(Path, "is_file", fake_is_file) + monkeypatch.setattr(Path, "stat", fake_stat) + + assert _detect_vendored(tmp_path) == [] + + +class TestCommitMessageHelpers: + def test_score_commit_messages_handles_empty_input(self) -> None: + assert _score_commit_messages([]) == (0.5, "No commits available") + + def test_score_commit_messages_uses_first_line_and_filters_lazy_messages(self) -> None: + commits = [ + _commit("feat: add durable cache\n\nExpanded body"), + _commit("wip"), + _commit("."), + _commit("Improve checkout path validation"), + {}, + ] + + assert _score_commit_messages(commits) == ( + 0.4, + "2/5 descriptive commits", + ) + + def test_classify_commits_reports_ratios_types_and_issue_refs(self) -> None: + messages = [ + "feat(ui): add summary panel #12", + "fix!: prevent stale writes", + "docs: explain operator workflow", + "Update dependency #44", + ] + + assert _classify_commits(messages) == { + "conventional_ratio": 0.75, + "commit_types": {"feat": 1, "fix": 1, "docs": 1}, + "has_issue_refs": 0.5, + } + + def test_classify_commits_handles_empty_input(self) -> None: + assert _classify_commits([]) == { + "conventional_ratio": 0, + "commit_types": {}, + "has_issue_refs": 0, + } + + +class TestRadonAnalysis: + def test_radon_analysis_returns_none_when_radon_is_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem(sys.modules, "radon", None) + + assert _radon_analysis(tmp_path) is None + + def test_radon_analysis_reports_maintainability_and_complexity( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (tmp_path / "simple.py").write_text("def simple():\n return 1\n") + (tmp_path / "empty.py").write_text("\n") + skipped = tmp_path / "vendor" + skipped.mkdir() + (skipped / "ignored.py").write_text("def ignored():\n return 2\n") + complexity = types.ModuleType("radon.complexity") + metrics = types.ModuleType("radon.metrics") + complexity.cc_visit = lambda source: [ + types.SimpleNamespace(complexity=4, name="simple"), + types.SimpleNamespace(complexity=18, name="hard"), + ] + metrics.mi_visit = lambda source, multi: 42.4 + monkeypatch.setitem(sys.modules, "radon", types.ModuleType("radon")) + monkeypatch.setitem(sys.modules, "radon.complexity", complexity) + monkeypatch.setitem(sys.modules, "radon.metrics", metrics) + + assert _radon_analysis(tmp_path) == { + "avg_maintainability_index": 42.4, + "worst_cc_function": "simple.py:hard", + "worst_cc_score": 18, + "complex_function_count": 1, + "python_files_analyzed": 1, + } + + def test_radon_analysis_skips_files_that_raise_during_metrics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (tmp_path / "bad.py").write_text("def bad():\n return 1\n") + complexity = types.ModuleType("radon.complexity") + metrics = types.ModuleType("radon.metrics") + complexity.cc_visit = lambda source: [] + + def raise_for_metrics(source: str, multi: bool) -> float: + raise ValueError("bad syntax") + + metrics.mi_visit = raise_for_metrics + monkeypatch.setitem(sys.modules, "radon", types.ModuleType("radon")) + monkeypatch.setitem(sys.modules, "radon.complexity", complexity) + monkeypatch.setitem(sys.modules, "radon.metrics", metrics) + + assert _radon_analysis(tmp_path) is None + + def test_radon_analysis_respects_max_file_limit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (tmp_path / "first.py").write_text("def first():\n return 1\n") + (tmp_path / "second.py").write_text("def second():\n return 2\n") + complexity = types.ModuleType("radon.complexity") + metrics = types.ModuleType("radon.metrics") + complexity.cc_visit = lambda source: [] + metrics.mi_visit = lambda source, multi: 30 + monkeypatch.setitem(sys.modules, "radon", types.ModuleType("radon")) + monkeypatch.setitem(sys.modules, "radon.complexity", complexity) + monkeypatch.setitem(sys.modules, "radon.metrics", metrics) + + result = _radon_analysis(tmp_path, max_files=1) + + assert result is not None + assert result["python_files_analyzed"] == 1 + assert result["avg_maintainability_index"] == 30.0 diff --git a/tests/test_community_profile.py b/tests/test_community_profile.py new file mode 100644 index 0000000..68bbfd5 --- /dev/null +++ b/tests/test_community_profile.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +from src.analyzers.community_profile import CommunityProfileAnalyzer +from src.models import RepoMetadata + + +class _CommunityProfileClient: + def __init__(self, profile: dict | None) -> None: + self.profile = profile + + def get_community_profile(self, owner: str, repo: str) -> dict | None: + assert owner == "user" + assert repo == "test-repo" + return self.profile + + +def test_no_github_client_skips_community_profile( + tmp_path: Path, sample_metadata: RepoMetadata +) -> None: + result = CommunityProfileAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.dimension == "community_profile" + assert result.score == 0.0 + assert result.details == {} + assert result.findings == ["Skipped community profile (no API client)"] + + +def test_falsy_community_profile_reports_fetch_failure( + tmp_path: Path, sample_metadata: RepoMetadata +) -> None: + client = _CommunityProfileClient(profile={}) + + result = CommunityProfileAnalyzer().analyze(tmp_path, sample_metadata, client) + + assert result.score == 0.0 + assert result.details == {} + assert result.findings == ["Could not fetch community profile"] + + +def test_api_present_and_missing_files_drive_score_details_and_findings( + tmp_path: Path, sample_metadata: RepoMetadata +) -> None: + client = _CommunityProfileClient( + profile={ + "health_percentage": 66, + "files": { + "readme": {"name": "README.md"}, + "license": None, + "code_of_conduct": {"name": "CODE_OF_CONDUCT.md"}, + "contributing": None, + "security": {"name": "SECURITY.md"}, + "issue_template": {"name": "bug_report.md"}, + "pull_request_template": None, + }, + } + ) + + result = CommunityProfileAnalyzer().analyze(tmp_path, sample_metadata, client) + + assert result.score == 4 / 8 + assert result.details == { + "health_percentage": 66, + "present": ["readme", "code_of_conduct", "security", "issue_template"], + "missing": ["license", "contributing", "pull_request_template"], + } + assert result.findings == [ + "Health files: readme, code_of_conduct, security, issue_template", + "Missing: license, contributing, pull_request_template", + "GitHub health score: 66%", + ] + + +def test_local_fallbacks_add_files_and_directories_when_api_reports_missing( + tmp_path: Path, sample_metadata: RepoMetadata +) -> None: + (tmp_path / ".github" / "ISSUE_TEMPLATE").mkdir(parents=True) + (tmp_path / ".github" / "PULL_REQUEST_TEMPLATE.md").write_text("## Summary\n") + (tmp_path / "SECURITY.md").write_text("# Security\n") + (tmp_path / ".github" / "CODE_OF_CONDUCT.md").write_text("# Code of Conduct\n") + (tmp_path / "CONTRIBUTING.md").write_text("# Contributing\n") + (tmp_path / "CHANGELOG").write_text("# Changelog\n") + client = _CommunityProfileClient( + profile={ + "health_percentage": 10, + "files": { + "readme": None, + "license": None, + "code_of_conduct": None, + "contributing": None, + "security": None, + "issue_template": None, + "pull_request_template": None, + }, + } + ) + + result = CommunityProfileAnalyzer().analyze(tmp_path, sample_metadata, client) + + assert result.score == 6 / 8 + assert result.details == { + "health_percentage": 10, + "present": [ + "security", + "code_of_conduct", + "issue_template", + "pull_request_template", + "contributing", + "changelog", + ], + "missing": ["readme", "license"], + } + assert result.findings == [ + "Health files: security, code_of_conduct, issue_template, " + + "pull_request_template, contributing, changelog", + "Missing: readme, license", + "GitHub health score: 10%", + ] + + +def test_all_api_templates_present_with_changelog_reaches_full_score( + tmp_path: Path, sample_metadata: RepoMetadata +) -> None: + (tmp_path / "CHANGELOG.md").write_text("# Changelog\n") + client = _CommunityProfileClient( + profile={ + "health_percentage": 100, + "files": { + "readme": {"name": "README.md"}, + "license": {"name": "LICENSE"}, + "code_of_conduct": {"name": "CODE_OF_CONDUCT.md"}, + "contributing": {"name": "CONTRIBUTING.md"}, + "security": {"name": "SECURITY.md"}, + "issue_template": {"name": "ISSUE_TEMPLATE.md"}, + "pull_request_template": {"name": "PULL_REQUEST_TEMPLATE.md"}, + }, + } + ) + + result = CommunityProfileAnalyzer().analyze(tmp_path, sample_metadata, client) + + assert result.score == 1.0 + assert result.details == { + "health_percentage": 100, + "present": [ + "readme", + "license", + "code_of_conduct", + "contributing", + "security", + "issue_template", + "pull_request_template", + "changelog", + ], + "missing": [], + } + assert result.findings == [ + "Health files: readme, license, code_of_conduct, contributing, security, " + + "issue_template, pull_request_template, changelog", + "GitHub health score: 100%", + ] diff --git a/tests/test_completeness.py b/tests/test_completeness.py new file mode 100644 index 0000000..f8997ba --- /dev/null +++ b/tests/test_completeness.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from pathlib import Path + +from src.analyzers.completeness import ( + BuildReadinessAnalyzer, + DocumentationAnalyzer, + _sample_comment_density, +) + + +def test_documentation_analyzer_scores_complete_documentation(tmp_path: Path) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "CHANGELOG.md").write_text("## 1.0\n", encoding="utf-8") + (tmp_path / "CONTRIBUTING.md").write_text("# Contributing\n", encoding="utf-8") + (tmp_path / "module.py").write_text( + "# module notes\n" + "# implementation note\n" + "def answer():\n" + " return 42\n", + encoding="utf-8", + ) + + result = DocumentationAnalyzer().analyze(tmp_path, metadata=None) + + assert result.dimension == "documentation" + assert result.score == 1.0 + assert result.findings == [ + "Has docs/ directory", + "Has CHANGELOG", + "Has CONTRIBUTING guide", + "Comment density: 50%", + ] + assert result.details == {"comment_ratio": 0.5} + + +def test_build_readiness_analyzer_scores_available_build_artifacts(tmp_path: Path) -> None: + (tmp_path / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8") + (tmp_path / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8") + (tmp_path / "justfile").write_text("test:\n pytest\n", encoding="utf-8") + (tmp_path / ".env.sample").write_text("TOKEN=\n", encoding="utf-8") + (tmp_path / "vercel.json").write_text("{}\n", encoding="utf-8") + (tmp_path / "Procfile").write_text("web: python app.py\n", encoding="utf-8") + + result = BuildReadinessAnalyzer().analyze(tmp_path, metadata=None) + + assert result.dimension == "build_readiness" + assert result.score == 1.0 + assert result.findings == [ + "Docker: Dockerfile, docker-compose.yml", + "Has justfile", + "Environment template: .env.sample", + "Deploy config: vercel.json, Procfile", + ] + assert result.details == { + "docker": ["Dockerfile", "docker-compose.yml"], + "deploy_configs": ["vercel.json", "Procfile"], + } + + +def test_analyzers_report_missing_documentation_and_build_artifacts(tmp_path: Path) -> None: + documentation = DocumentationAnalyzer().analyze(tmp_path, metadata=None) + build_readiness = BuildReadinessAnalyzer().analyze(tmp_path, metadata=None) + + assert documentation.score == 0.0 + assert documentation.findings == [ + "No docs/ directory", + "No CHANGELOG", + "No CONTRIBUTING guide", + "Could not assess comment density", + ] + assert documentation.details == {"comment_ratio": None} + assert build_readiness.score == 0.0 + assert build_readiness.findings == [ + "No Docker configuration", + "No Makefile or build script", + "No .env.example", + "No deployment configuration", + ] + assert build_readiness.details == {"deploy_configs": []} + + +def test_sample_comment_density_skips_binary_hidden_node_and_oversized_files( + tmp_path: Path, + monkeypatch, +) -> None: + (tmp_path / "image.png").write_bytes(b"binary") + hidden_dir = tmp_path / ".github" + hidden_dir.mkdir() + (hidden_dir / "workflow.yml").write_text("# skipped\nname: ci\n", encoding="utf-8") + node_dir = tmp_path / "node_modules" + node_dir.mkdir() + (node_dir / "package.js").write_text("// skipped\nconst x = 1;\n", encoding="utf-8") + oversized = tmp_path / "generated.py" + oversized.write_text("# skipped\nvalue = 1\n", encoding="utf-8") + kept = tmp_path / "kept.py" + kept.write_text("# counted\nvalue = 1\n", encoding="utf-8") + + original_stat = Path.stat + + def fake_stat(path: Path, *args, **kwargs): + stat_result = original_stat(path, *args, **kwargs) + if path == oversized: + return type( + "FakeStat", + (), + {"st_mode": stat_result.st_mode, "st_size": 1_000_001}, + )() + return stat_result + + monkeypatch.setattr(Path, "stat", fake_stat) + + assert _sample_comment_density(tmp_path) == 0.5 + + +def test_sample_comment_density_ignores_stat_and_read_errors( + tmp_path: Path, + monkeypatch, +) -> None: + stat_error = tmp_path / "stat_error.py" + stat_error.write_text("# skipped before sample\nvalue = 1\n", encoding="utf-8") + read_error = tmp_path / "read_error.py" + read_error.write_text("# skipped while sampled\nvalue = 1\n", encoding="utf-8") + kept = tmp_path / "kept.js" + kept.write_text("// counted\nconst value = 1;\n", encoding="utf-8") + + original_stat = Path.stat + original_is_file = Path.is_file + original_read_text = Path.read_text + + def fake_is_file(path: Path) -> bool: + if path == stat_error: + return True + return original_is_file(path) + + def fake_stat(path: Path, *args, **kwargs): + if path == stat_error: + raise OSError("stat failed") + return original_stat(path, *args, **kwargs) + + def fake_read_text(path: Path, *args, **kwargs): + if path == read_error: + raise OSError("read failed") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "is_file", fake_is_file) + monkeypatch.setattr(Path, "stat", fake_stat) + monkeypatch.setattr(Path, "read_text", fake_read_text) + + assert _sample_comment_density(tmp_path) == 0.5 diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..4ea46da --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,249 @@ +"""Focused coverage for dependency manifest parsing and analyzer edge cases.""" + +from __future__ import annotations + +import sys +import types + +from src.analyzers.dependencies import DependenciesAnalyzer, _count_dependencies + + +class TestDependenciesCacheInputsHash: + def test_hashes_present_lockfiles_and_manifests(self, tmp_repo, sample_metadata): + """Cache fingerprints include present dependency lockfiles and manifests.""" + (tmp_repo / "package-lock.json").write_text('{"lockfileVersion": 3}\n') + analyzer = DependenciesAnalyzer() + + digest = analyzer.cache_inputs_hash(tmp_repo, sample_metadata) + + assert isinstance(digest, str) + assert len(digest) == 64 + + def test_hash_ignores_unreadable_dependency_file(self, tmp_repo, sample_metadata): + """Unreadable dependency files are skipped instead of failing fingerprinting.""" + unreadable = tmp_repo / "package-lock.json" + unreadable.write_text("locked\n") + readable = tmp_repo / "package.json" + readable.write_text('{"dependencies": {"click": "^8.0.0"}}\n') + unreadable.chmod(0) + try: + digest = DependenciesAnalyzer().cache_inputs_hash(tmp_repo, sample_metadata) + finally: + unreadable.chmod(0o644) + + assert isinstance(digest, str) + assert len(digest) == 64 + + def test_hash_returns_none_without_dependency_files(self, empty_repo, sample_metadata): + """Repos without dependency files do not receive a cache fingerprint.""" + assert DependenciesAnalyzer().cache_inputs_hash(empty_repo, sample_metadata) is None + + def test_hash_returns_none_without_repo_path(self, sample_metadata): + """Missing local repo paths run uncached because there are no inputs to hash.""" + assert DependenciesAnalyzer().cache_inputs_hash(None, sample_metadata) is None + + +class TestCountDependencies: + def test_counts_package_json_dependencies_and_dev_dependencies(self, tmp_path): + """package.json counts dependencies and devDependencies together.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "package.json").write_text( + '{"dependencies": {"react": "^19.0.0"}, "devDependencies": {"vite": "^6.0.0"}}' + ) + + assert _count_dependencies(repo, ["package.json"]) == 2 + + def test_invalid_package_json_falls_through_to_requirements(self, tmp_path): + """Invalid package.json is ignored so a later manifest can provide the count.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "package.json").write_text("{not-json") + (repo / "requirements.txt").write_text("requests\n") + + assert _count_dependencies(repo, ["package.json", "requirements.txt"]) == 1 + + def test_package_json_oserror_falls_through_to_requirements(self, tmp_path): + """Unreadable package.json is ignored so requirements.txt can be parsed.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "package.json").symlink_to(repo / "missing-package.json") + (repo / "requirements.txt").write_text("requests\nclick\n") + + assert _count_dependencies(repo, ["package.json", "requirements.txt"]) == 2 + + def test_counts_requirements_txt_install_lines_only(self, tmp_path): + """requirements.txt ignores comments, blanks, and option lines.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "requirements.txt").write_text( + "\n# comment\n-r base.txt\n--extra-index-url https://example.test\nrequests\nclick>=8\n" + ) + + assert _count_dependencies(repo, ["requirements.txt"]) == 2 + + def test_requirements_oserror_falls_through_to_cargo(self, tmp_path): + """Unreadable requirements.txt is ignored so Cargo.toml can be parsed.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "requirements.txt").symlink_to(repo / "missing-requirements.txt") + (repo / "Cargo.toml").write_text('[dependencies]\nserde = "1"\n') + + assert _count_dependencies(repo, ["requirements.txt", "Cargo.toml"]) == 1 + + def test_counts_cargo_dependencies_section_until_next_section(self, tmp_path): + """Cargo.toml counts assignments in [dependencies] and stops at the next section.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "Cargo.toml").write_text( + '[package]\nname = "demo"\n[dependencies]\nserde = "1"\ntokio = "1"\n[dev-dependencies]\npretty_assertions = "1"\n' + ) + + assert _count_dependencies(repo, ["Cargo.toml"]) == 2 + + def test_cargo_without_dependencies_section_returns_zero(self, tmp_path): + """Cargo.toml without [dependencies] is parseable as zero dependencies.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "Cargo.toml").write_text('[package]\nname = "demo"\n') + + assert _count_dependencies(repo, ["Cargo.toml"]) == 0 + + def test_cargo_oserror_falls_through_to_go_mod(self, tmp_path): + """Unreadable Cargo.toml is ignored so go.mod can be parsed.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "Cargo.toml").symlink_to(repo / "missing-cargo.toml") + (repo / "go.mod").write_text("module example.test/demo\n\nrequire (\ngolang.org/x/text v0.14.0\n)\n") + + assert _count_dependencies(repo, ["Cargo.toml", "go.mod"]) == 1 + + def test_counts_go_mod_require_block(self, tmp_path): + """go.mod counts nonblank entries inside a require block.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "go.mod").write_text( + "module example.test/demo\n\nrequire (\ngolang.org/x/text v0.14.0\ngithub.com/google/uuid v1.6.0\n)\n" + ) + + assert _count_dependencies(repo, ["go.mod"]) == 2 + + def test_go_mod_without_require_returns_zero(self, tmp_path): + """go.mod without a require section is parseable as zero dependencies.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "go.mod").write_text("module example.test/demo\n") + + assert _count_dependencies(repo, ["go.mod"]) == 0 + + def test_go_mod_oserror_falls_through_to_pyproject(self, tmp_path): + """Unreadable go.mod is ignored so pyproject.toml can be parsed.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "go.mod").symlink_to(repo / "missing-go.mod") + (repo / "pyproject.toml").write_text('[project]\ndependencies = [\n"requests",\n]\n') + + assert _count_dependencies(repo, ["go.mod", "pyproject.toml"]) == 1 + + def test_counts_pyproject_dependencies_list(self, tmp_path): + """pyproject.toml counts quoted dependency entries in a dependencies list.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").write_text('[project]\ndependencies = [\n"requests",\n"click",\n]\n') + + assert _count_dependencies(repo, ["pyproject.toml"]) == 2 + + def test_pyproject_without_dependencies_found_returns_none(self, tmp_path): + """pyproject.toml without counted dependency entries returns an unknown count.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").write_text('[project]\nname = "demo"\n') + + assert _count_dependencies(repo, ["pyproject.toml"]) is None + + def test_pyproject_oserror_returns_none(self, tmp_path): + """Unreadable pyproject.toml leaves dependency count unknown.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").symlink_to(repo / "missing-pyproject.toml") + + assert _count_dependencies(repo, ["pyproject.toml"]) is None + + +class TestDependenciesAnalyzerFindings: + def test_excessive_dependencies_finding_for_large_package_json(self, tmp_path, sample_metadata): + """More than 500 dependencies emits the excessive dependency finding.""" + repo = tmp_path / "repo" + repo.mkdir() + deps = ",".join(f'"dep{i}": "1.0.0"' for i in range(501)) + (repo / "package.json").write_text(f'{{"dependencies": {{{deps}}}}}') + + result = DependenciesAnalyzer().analyze(repo, sample_metadata) + + assert result.details["dep_count"] == 501 + assert "Excessive dependencies: 501" in result.findings + + def test_zero_dependencies_finding_for_empty_cargo_manifest(self, tmp_path, sample_metadata): + """A parseable manifest with zero dependencies emits the zero-dependencies finding.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "Cargo.toml").write_text('[package]\nname = "demo"\n') + + result = DependenciesAnalyzer().analyze(repo, sample_metadata) + + assert result.details["dep_count"] == 0 + assert "Zero dependencies declared" in result.findings + + def test_unknown_dependency_count_finding_without_parseable_manifest(self, empty_repo, sample_metadata): + """No supported manifest count emits the could-not-determine finding.""" + result = DependenciesAnalyzer().analyze(empty_repo, sample_metadata) + + assert result.details["dep_count"] is None + assert "Could not determine dependency count" in result.findings + + def test_libyears_merge_preserves_existing_dependency_count( + self, tmp_path, sample_metadata, monkeypatch + ): + """Libyears details are merged while preserving the analyzer's dependency count.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "requirements.txt").write_text("requests\nclick\n") + + fake_cache_module = types.ModuleType("src.cache") + fake_cache_module.ResponseCache = lambda ttl: {"ttl": ttl} + fake_libyears_module = types.ModuleType("src.libyears") + fake_libyears_module.compute_libyears = lambda repo_path, manifests, cache: { + "dep_count": 999, + "total_libyears": 4.5, + } + monkeypatch.setitem(sys.modules, "src.cache", fake_cache_module) + monkeypatch.setitem(sys.modules, "src.libyears", fake_libyears_module) + + result = DependenciesAnalyzer().analyze(repo, sample_metadata) + + assert result.details["dep_count"] == 2 + assert result.details["total_libyears"] == 4.5 + assert "Libyears: 4.5" in result.findings + + def test_libyears_failure_is_non_fatal(self, tmp_path, sample_metadata, monkeypatch): + """Libyears exceptions are swallowed so dependency analysis still returns.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "requirements.txt").write_text("requests\n") + + fake_cache_module = types.ModuleType("src.cache") + fake_cache_module.ResponseCache = lambda ttl: {"ttl": ttl} + fake_libyears_module = types.ModuleType("src.libyears") + + def fail_compute_libyears(repo_path, manifests, cache): + raise RuntimeError("registry unavailable") + + fake_libyears_module.compute_libyears = fail_compute_libyears + monkeypatch.setitem(sys.modules, "src.cache", fake_cache_module) + monkeypatch.setitem(sys.modules, "src.libyears", fake_libyears_module) + + result = DependenciesAnalyzer().analyze(repo, sample_metadata) + + assert result.details["dep_count"] == 1 + assert "Dependency count: 1" in result.findings + assert "total_libyears" not in result.details diff --git a/tests/test_interest.py b/tests/test_interest.py index 0d7502d..cc7ff2f 100644 --- a/tests/test_interest.py +++ b/tests/test_interest.py @@ -1,9 +1,16 @@ from __future__ import annotations +import pytest + from src.analyzers.interest import ( InterestAnalyzer, + _burst_coefficient, + _count_assets, + _estimate_loc, + _score_ambition, _score_commit_bursts, _score_description, + _score_readme_storytelling, _score_recency, ) from src.models import RepoMetadata @@ -42,6 +49,33 @@ def test_long_unique_description(self): )) assert score >= 0.15 + def test_analyze_reports_rich_project_description(self, tmp_repo): + result = InterestAnalyzer().analyze( + tmp_repo, + _meta( + name="weather", + description=( + "A detailed local weather analysis dashboard with alerts, " + "maps, climate comparisons, and historical trend summaries" + ), + ), + ) + assert result.details["description_score"] == pytest.approx(0.15) + assert "Rich project description" in result.findings + + def test_analyze_reports_basic_description(self, tmp_repo): + result = InterestAnalyzer().analyze( + tmp_repo, + _meta(name="weather", description="Weather tracker app"), + ) + assert result.details["description_score"] == 0.05 + assert "Basic description" in result.findings + + def test_analyze_reports_no_description(self, tmp_repo): + result = InterestAnalyzer().analyze(tmp_repo, _meta(description=None)) + assert result.details["description_score"] == 0.0 + assert "No description" in result.findings + class TestCommitBursts: def test_empty_weeks(self): @@ -60,6 +94,57 @@ def test_bursty_commits(self): def test_single_week(self): assert _score_commit_bursts([10]) == 0.0 + def test_inactive_weeks_only_do_not_score(self): + assert _score_commit_bursts([0, 0, 7, 0]) == 0.0 + + def test_medium_high_variance_scores_ten_points(self): + assert _score_commit_bursts([1, 1, 1, 5]) == 0.10 + + def test_moderate_variance_scores_five_points(self): + assert _score_commit_bursts([1, 1, 2, 3]) == 0.05 + + +class TestBurstCoefficient: + def test_empty_and_short_series_return_zero(self): + assert _burst_coefficient([]) == 0.0 + assert _burst_coefficient([0, 0, 9]) == 0.0 + + def test_computes_rounded_coefficient_for_active_weeks(self): + assert _burst_coefficient([0, 1, 1, 2, 3]) == 0.55 + + def test_zero_mean_branch_is_unreachable_after_positive_week_filter(self): + # The production filter keeps only w > 0, so any remaining active week + # makes the mean positive and this still returns the short-series guard. + assert _burst_coefficient([0, 0, 0, 0]) == 0.0 + + +class TestAnalyzeCommitBursts: + class _GitHubClientStub: + def __init__(self, owner_weeks): + self.owner_weeks = owner_weeks + + def get_participation_stats(self, owner, repo): + assert (owner, repo) == ("user", "test") + return {"owner": self.owner_weeks} + + def test_analyze_reports_passionate_burst_pattern(self, tmp_repo): + result = InterestAnalyzer().analyze( + tmp_repo, + _meta(), + github_client=self._GitHubClientStub([1, 1, 50, 2, 1, 1]), + ) + assert result.details["burst_coefficient"] > 1.0 + assert "Passionate burst development pattern" in result.findings + + def test_analyze_reports_some_development_bursts(self, tmp_repo): + result = InterestAnalyzer().analyze( + tmp_repo, + _meta(), + github_client=self._GitHubClientStub([1, 1, 2, 3]), + ) + assert result.details["burst_coefficient"] == 0.55 + assert "Some development bursts" in result.findings + class TestRecencyBonus: def test_recent_push_gets_max(self): @@ -102,6 +187,24 @@ def test_common_language(self, tmp_repo): assert details["tech_novelty"] == 0.0 +class TestTopicScoring: + def test_three_topics_receive_full_topic_score(self, tmp_repo): + result = InterestAnalyzer().analyze( + tmp_repo, + _meta(topics=["portfolio", "automation", "analytics"]), + ) + assert result.details["topic_count"] == 3 + assert result.score >= 0.10 + assert "Topics: portfolio, automation, analytics" in result.findings + + +class TestExternalValidation: + def test_stars_and_forks_add_validation_findings(self, tmp_repo): + result = InterestAnalyzer().analyze(tmp_repo, _meta(stars=3, forks=2)) + assert "Stars: 3" in result.findings + assert "Forks: 2" in result.findings + + class TestProjectAmbition: def test_large_repo_with_assets(self, tmp_repo): # Add some code and assets @@ -112,6 +215,85 @@ def test_large_repo_with_assets(self, tmp_repo): result = InterestAnalyzer().analyze(tmp_repo, meta) assert result.details["ambition"]["asset_count"] >= 2 + def test_large_loc_repo_gets_ambition_bonus(self, tmp_repo): + baseline_loc = _estimate_loc(tmp_repo) + (tmp_repo / "main.py").write_text("\n".join(f"line_{i} = {i}" for i in range(1001))) + score, details = _score_ambition(tmp_repo, _meta()) + assert details["estimated_loc"] == baseline_loc + 1001 + assert score >= 0.10 + + def test_loc_estimate_stops_after_max_files(self, tmp_repo): + repo = tmp_repo / "many-files" + repo.mkdir() + for index in range(205): + (repo / f"file_{index:03}.py").write_text("x = 1\n") + assert _estimate_loc(repo) == 200 + + def test_loc_estimate_skips_dotfiles_and_node_modules(self, tmp_repo): + repo = tmp_repo / "skip-paths" + repo.mkdir() + (repo / ".hidden").mkdir() + (repo / ".hidden" / "ignored.py").write_text("x = 1\n" * 50) + (repo / "node_modules").mkdir() + (repo / "node_modules" / "ignored.js").write_text("x = 1\n" * 50) + (repo / "visible.py").write_text("a = 1\nb = 2\nc = 3\n") + assert _estimate_loc(repo) == 3 + + def test_loc_estimate_continues_after_oserror(self, tmp_repo, monkeypatch): + repo = tmp_repo / "oserror" + repo.mkdir() + bad_file = repo / "bad.py" + good_file = repo / "good.py" + bad_file.write_text("unreadable\n") + good_file.write_text("a = 1\nb = 2\n") + original_read_text = type(bad_file).read_text + + def raise_for_bad_files(path, *args, **kwargs): + if path == bad_file: + raise OSError("cannot read") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(type(bad_file), "read_text", raise_for_bad_files) + assert _estimate_loc(repo) == 2 + + def test_asset_count_stops_after_max_scan(self, tmp_repo): + repo = tmp_repo / "many-assets" + repo.mkdir() + scanned_files = [] + for index in range(500): + path = repo / f"{index:03}.txt" + path.write_text("not an asset\n") + scanned_files.append(path) + late_asset = repo / "late.png" + late_asset.write_bytes(b"PNG") + + class FakeRepo: + def rglob(self, pattern): + assert pattern == "*" + yield from scanned_files + yield late_asset + + assert _count_assets(FakeRepo()) == 0 + + def test_asset_count_counts_asset_within_max_scan(self, tmp_repo): + repo = tmp_repo / "early-asset" + repo.mkdir() + scanned_files = [] + for index in range(10): + path = repo / f"{index:03}.txt" + path.write_text("not an asset\n") + scanned_files.append(path) + early_asset = repo / "early.png" + early_asset.write_bytes(b"PNG") + + class FakeRepo: + def rglob(self, pattern): + assert pattern == "*" + yield from scanned_files + yield early_asset + + assert _count_assets(FakeRepo()) == 1 + class TestReadmeStorytelling: def test_long_readme_with_images(self, tmp_repo): @@ -127,3 +309,18 @@ def test_short_readme_no_images(self, tmp_repo): (tmp_repo / "README.md").write_text("# Short\n\nBrief.\n") result = InterestAnalyzer().analyze(tmp_repo, _meta()) assert result.details["readme_storytelling"] == 0.0 + + def test_missing_readme_scores_zero(self, tmp_repo): + repo = tmp_repo / "no-readme" + repo.mkdir() + assert _score_readme_storytelling(repo) == 0.0 + + def test_readme_oserror_scores_zero(self, tmp_repo, monkeypatch): + readme = tmp_repo / "README.md" + readme.write_text("# Present but unreadable\n") + + def raise_oserror(*args, **kwargs): + raise OSError("cannot read") + + monkeypatch.setattr(type(readme), "read_text", raise_oserror) + assert _score_readme_storytelling(tmp_repo) == 0.0 diff --git a/tests/test_testing_analyzer.py b/tests/test_testing_analyzer.py new file mode 100644 index 0000000..eadf798 --- /dev/null +++ b/tests/test_testing_analyzer.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from src.analyzers.testing import TestingAnalyzer + + +class TestTestingAnalyzer: + def test_find_test_files_caps_results_at_500(self, tmp_path: Path, sample_metadata) -> None: + for index in range(501): + (tmp_path / f"case_{index}_test.py").write_text("def test_case():\n assert True\n") + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.details["test_file_count"] == 500 + assert result.score == 0.7 + assert "Found 500 test file(s)" in result.findings + assert "Test files: 500" in result.findings + + @pytest.mark.parametrize( + ("dependency", "framework"), + [ + ("vitest", "vitest"), + ("jest", "jest"), + ("mocha", "mocha"), + ("@playwright/test", "playwright"), + ("cypress", "cypress"), + ], + ) + def test_detects_javascript_test_frameworks_from_package_json( + self, + tmp_path: Path, + sample_metadata, + dependency: str, + framework: str, + ) -> None: + (tmp_path / "package.json").write_text( + json.dumps({"devDependencies": {dependency: "latest"}}) + ) + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.score == 0.3 + assert result.details["framework"] == framework + assert f"Test framework: {framework}" in result.findings + assert "Zero test files" in result.findings + + def test_invalid_package_json_falls_through_to_pytest_pyproject( + self, + tmp_path: Path, + sample_metadata, + ) -> None: + (tmp_path / "package.json").write_text("{not valid json") + (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\n") + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.score == 0.3 + assert result.details["framework"] == "pytest" + assert "Test framework: pytest" in result.findings + + def test_detects_unittest_from_pyproject(self, tmp_path: Path, sample_metadata) -> None: + (tmp_path / "pyproject.toml").write_text("[tool.unittest]\n") + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.score == 0.3 + assert result.details["framework"] == "unittest" + assert "Test framework: unittest" in result.findings + + def test_detects_pytest_from_requirements_files( + self, + tmp_path: Path, + sample_metadata, + ) -> None: + (tmp_path / "requirements-dev.txt").write_text("requests\npytest>=8\n") + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.score == 0.3 + assert result.details["framework"] == "pytest" + assert "Test framework: pytest" in result.findings + + def test_detects_cargo_dev_dependencies(self, tmp_path: Path, sample_metadata) -> None: + (tmp_path / "Cargo.toml").write_text( + "[package]\nname = \"demo\"\n\n[dev-dependencies]\npretty_assertions = \"1\"\n" + ) + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.score == 0.3 + assert result.details["framework"] == "cargo-test" + assert "Test framework: cargo-test" in result.findings + + def test_detects_go_test_files_without_config(self, tmp_path: Path, sample_metadata) -> None: + (tmp_path / "math_test.go").write_text("package demo\n") + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.score == 1.0 + assert result.details["framework"] == "go-test" + assert result.details["test_file_count"] == 1 + assert "Test framework: go-test" in result.findings + + def test_node_modules_test_files_are_ignored( + self, + tmp_path: Path, + sample_metadata, + ) -> None: + node_modules = tmp_path / "node_modules" / "dep" + node_modules.mkdir(parents=True) + (node_modules / "dep.test.js").write_text("test('dep', () => {});\n") + + result = TestingAnalyzer().analyze(tmp_path, sample_metadata) + + assert result.score == 0.0 + assert result.details["test_file_count"] == 0 + assert result.details["framework"] is None + assert "No test directories or test files found" in result.findings + assert "No test framework configured" in result.findings + assert "Zero test files" in result.findings