diff --git a/src/manus_agent/tools/get_dependency_blast_radius.py b/src/manus_agent/tools/get_dependency_blast_radius.py index 876527a..c55fd3d 100644 --- a/src/manus_agent/tools/get_dependency_blast_radius.py +++ b/src/manus_agent/tools/get_dependency_blast_radius.py @@ -14,6 +14,8 @@ 5. PyPI JSON API — package metadata (PyPI only; download counts from pypistats.org with graceful degradation on 429) 6. Maven Central — artifact metadata + version count (Maven only) + 7. Packagist API — PHP package metadata, total/monthly downloads, + dependents count, favers, first-release date (PHP only) The tool deliberately avoids paid/rate-limited APIs so it works without configuration. When a source is unavailable the result degrades gracefully. @@ -51,6 +53,7 @@ _PYPI_JSON_URL = "https://pypi.org/pypi/{}/json" _PYPISTATS_URL = "https://pypistats.org/api/packages/{}/recent" _MAVEN_SEARCH_URL = "https://search.maven.org/solrsearch/select" +_PACKAGIST_PKG_URL = "https://packagist.org/packages/{}.json" _TIMEOUT = 20 @@ -373,6 +376,122 @@ def _enrich_maven(name: str) -> dict[str, Any]: return result +def _extract_packagist_latest_stable(versions: dict[str, Any]) -> str: + """Return the latest non-dev version string, or empty string if none found.""" + stable: list[str] = [] + for vname in versions: + # Skip dev/branch aliases: contain "-dev", "dev-", or ".x-dev" + if "dev" in vname.lower(): + continue + stable.append(vname) + if not stable: + return "" + + # Sort descending by the version_normalized field if available, else lexicographic + def _sort_key(v: str) -> str: + vdata = versions.get(v, {}) + return vdata.get("version_normalized", v) + + stable.sort(key=_sort_key, reverse=True) + return stable[0] + + +def _extract_packagist_first_release(pkg: dict[str, Any]) -> str: + """ + Return the earliest release timestamp as an ISO-8601 string. + + Uses the package-level ``time`` field (first indexed date). If absent, + falls back to the oldest ``time`` value across all version entries. + """ + # Top-level `time` is the package creation/first-indexed date + top_time = pkg.get("time", "") + if top_time: + return top_time + + # Fallback: scan version timestamps + versions = pkg.get("versions", {}) + times: list[str] = [] + for vdata in versions.values(): + t = vdata.get("time", "") + if t: + times.append(t) + if not times: + return "" + times.sort() + return times[0] + + +def _enrich_packagist(name: str) -> dict[str, Any]: + """ + Fetch Packagist package metadata for a PHP package. + + Accepts both ``vendor/package`` and plain ``package`` forms. If no vendor + prefix is present the name is used as-is (some single-word legacy packages + exist, though modern PHP packages are always namespaced). + + Single API call to ``https://packagist.org/packages/{name}.json``. + Returns total/monthly/daily downloads, dependent package count, favers + (Packagist stars), latest stable version, first-release date, age in years, + total version count, and a short description. + """ + import datetime + + result: dict[str, Any] = {"ecosystem": "Packagist", "package_name": name} + try: + url = _PACKAGIST_PKG_URL.format(name) + data = _get(url) + pkg = data.get("package", {}) + + # Download stats + downloads = pkg.get("downloads", {}) + result["total_downloads"] = downloads.get("total", 0) + result["monthly_downloads"] = downloads.get("monthly", 0) + result["daily_downloads"] = downloads.get("daily", 0) + # Use monthly/4 as a weekly-downloads proxy so _blast_score works + monthly = downloads.get("monthly") or 0 + result["weekly_downloads"] = int(monthly / 4) if monthly else None + + # Dependents + social + result["dependent_packages_count"] = pkg.get("dependents", 0) + result["favers"] = pkg.get("favers", 0) + + # Description + desc = pkg.get("description", "") or "" + result["description"] = desc[:120] + + # Versions + versions = pkg.get("versions", {}) + result["total_versions"] = len(versions) + latest_stable = _extract_packagist_latest_stable(versions) + if latest_stable: + result["latest_version"] = latest_stable + + # First-release date + age + first_ts = _extract_packagist_first_release(pkg) + if first_ts: + result["first_release_date"] = first_ts + try: + # Normalise ISO-8601: handle Z suffix and +HH:MM offset + ts_clean = first_ts.rstrip("Z").replace("+00:00", "") + # Truncate sub-second to 6 digits + if "." in ts_clean: + dot_idx = ts_clean.index(".") + ts_clean = ts_clean[: dot_idx + 7] + first_dt = datetime.datetime.fromisoformat(ts_clean) + age_years = (datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - first_dt).days / 365.25 + result["age_years"] = round(age_years, 1) + except Exception: + pass + + # Homepage + if pkg.get("repository"): + result["home_page"] = pkg["repository"] + + except Exception as exc: + logger.debug("Packagist enrich failed for %s: %s", name, exc) + return result + + def _enrich_package(name: str, ecosystem: str) -> dict[str, Any]: """Dispatch to the right enrichment function based on ecosystem.""" eco_lower = (ecosystem or "").lower() @@ -382,6 +501,8 @@ def _enrich_package(name: str, ecosystem: str) -> dict[str, Any]: return _enrich_pypi(name) elif eco_lower in ("maven", "java", "gradle"): return _enrich_maven(name) + elif eco_lower in ("packagist", "php", "composer"): + return _enrich_packagist(name) # Unknown ecosystem — return minimal record return {"ecosystem": ecosystem, "package_name": name} @@ -433,6 +554,7 @@ def get_dependency_blast_radius( # noqa: C901 - **npm**: dependent package count + weekly/monthly download stats - **PyPI**: package metadata + download stats (when pypistats is available) - **Maven**: artifact metadata from Maven Central + - **Packagist**: total/monthly downloads, dependent count, favers, first-release date 3. Computes a qualitative blast-radius label: CRITICAL / HIGH / MEDIUM / LOW. Use after ``get_nvd_data`` to understand *how many projects are exposed* @@ -559,6 +681,26 @@ def get_dependency_blast_radius( # noqa: C901 if r.get("version_count"): lines.append(f" Version count: {r['version_count']}") + # Packagist-specific stats + if eco.lower() in ("packagist", "php", "composer"): + if r.get("latest_version"): + lines.append(f" Latest version: {r['latest_version']}") + if r.get("total_versions"): + lines.append(f" Total versions: {r['total_versions']:,}") + if r.get("total_downloads") is not None: + lines.append(f" Total downloads: {r['total_downloads']:,}") + if r.get("dependent_packages_count") is not None: + lines.append(f" PHP dependents: {r['dependent_packages_count']:,}") + if r.get("favers") is not None: + lines.append(f" Packagist favers: {r['favers']:,}") + if r.get("first_release_date"): + age = f" ({r['age_years']} years old)" if r.get("age_years") else "" + lines.append(f" First released: {r['first_release_date'][:10]}{age}") + if r.get("description"): + lines.append(f" Description: {r['description'][:80]}") + if r.get("home_page"): + lines.append(f" Repository: {r['home_page']}") + if source: lines.append(f" Data sources: {source}") @@ -577,6 +719,6 @@ def get_dependency_blast_radius( # noqa: C901 if total_weekly: sections.append(f" Total weekly downloads across all packages: {total_weekly:,}") if total_dependents: - sections.append(f" Total npm dependent packages: {total_dependents:,}") + sections.append(f" Total dependent packages: {total_dependents:,}") return "\n".join(sections) diff --git a/tests/test_dependency_blast_radius.py b/tests/test_dependency_blast_radius.py index 4e8d4b5..452cafb 100644 --- a/tests/test_dependency_blast_radius.py +++ b/tests/test_dependency_blast_radius.py @@ -17,7 +17,10 @@ _enrich_maven, _enrich_npm, _enrich_package, + _enrich_packagist, _enrich_pypi, + _extract_packagist_first_release, + _extract_packagist_latest_stable, _fetch_ghsa_affected, _fetch_nvd_affected, _fetch_osv_affected, @@ -637,10 +640,240 @@ def test_no_docs_returns_minimal_record(self): # =========================================================================== -# _enrich_package dispatch +# _extract_packagist_latest_stable # =========================================================================== +class TestExtractPackagistLatestStable: + def _make_versions(self, names: list[str]) -> dict[str, Any]: + """Build a minimal versions dict keyed by version name.""" + return {v: {"version": v, "version_normalized": v} for v in names} + + def test_returns_latest_stable_version(self): + versions = self._make_versions(["1.0.0", "1.1.0", "1.2.0"]) + assert _extract_packagist_latest_stable(versions) == "1.2.0" + + def test_skips_dev_branch_aliases(self): + versions = self._make_versions(["1.0.0", "2.0.x-dev", "dev-main", "1.1.0"]) + result = _extract_packagist_latest_stable(versions) + assert "dev" not in result.lower() + assert result in ("1.0.0", "1.1.0") + + def test_returns_empty_when_only_dev_versions(self): + versions = self._make_versions(["dev-main", "1.0.x-dev", "2.0.x-dev"]) + assert _extract_packagist_latest_stable(versions) == "" + + def test_returns_empty_for_empty_versions(self): + assert _extract_packagist_latest_stable({}) == "" + + def test_uses_version_normalized_for_sort(self): + # version_normalized gives proper sort order + versions = { + "v1.0.0": {"version": "v1.0.0", "version_normalized": "1.0.0.0"}, + "v2.0.0": {"version": "v2.0.0", "version_normalized": "2.0.0.0"}, + "v1.5.0": {"version": "v1.5.0", "version_normalized": "1.5.0.0"}, + } + result = _extract_packagist_latest_stable(versions) + assert result == "v2.0.0" + + +# =========================================================================== +# _extract_packagist_first_release +# =========================================================================== + + +class TestExtractPackagistFirstRelease: + def test_uses_top_level_time_field(self): + pkg = {"time": "2015-03-12T10:00:00+00:00", "versions": {}} + assert _extract_packagist_first_release(pkg) == "2015-03-12T10:00:00+00:00" + + def test_falls_back_to_oldest_version_time(self): + pkg = { + "versions": { + "1.0.0": {"time": "2018-06-01T00:00:00+00:00"}, + "1.1.0": {"time": "2019-01-15T00:00:00+00:00"}, + "0.9.0": {"time": "2017-11-20T00:00:00+00:00"}, + } + } + result = _extract_packagist_first_release(pkg) + assert result == "2017-11-20T00:00:00+00:00" + + def test_returns_empty_when_no_times(self): + pkg = {"versions": {"1.0.0": {"name": "foo/bar"}}} + assert _extract_packagist_first_release(pkg) == "" + + def test_returns_empty_for_empty_package(self): + assert _extract_packagist_first_release({}) == "" + + def test_top_level_time_preferred_over_versions(self): + # Even if a version has an earlier timestamp, top-level time wins + pkg = { + "time": "2020-01-01T00:00:00+00:00", + "versions": { + "1.0.0": {"time": "2010-01-01T00:00:00+00:00"}, + }, + } + assert _extract_packagist_first_release(pkg) == "2020-01-01T00:00:00+00:00" + + +# =========================================================================== +# _enrich_packagist +# =========================================================================== + + +class TestEnrichPackagist: + def _make_packagist_response(self, name: str = "symfony/http-foundation") -> dict[str, Any]: + """Build a representative Packagist API response.""" + return { + "package": { + "name": name, + "description": "Defines an object-oriented layer for the HTTP specification", + "time": "2011-10-16T03:42:31+00:00", + "repository": "https://github.com/symfony/http-foundation", + "downloads": { + "total": 931_081_858, + "monthly": 16_475_574, + "daily": 583_621, + }, + "favers": 8707, + "dependents": 5929, + "versions": { + "v7.3.0": { + "version": "v7.3.0", + "version_normalized": "7.3.0.0", + "time": "2025-01-01T00:00:00+00:00", + }, + "v6.4.0": { + "version": "v6.4.0", + "version_normalized": "6.4.0.0", + "time": "2023-11-01T00:00:00+00:00", + }, + "v7.4.x-dev": { + "version": "v7.4.x-dev", + "version_normalized": "7.4.9999999.9999999-dev", + "time": "2026-01-01T00:00:00+00:00", + }, + }, + } + } + + def test_returns_package_metadata(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._make_packagist_response() + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + assert result["ecosystem"] == "Packagist" + assert result["package_name"] == "symfony/http-foundation" + assert result["total_downloads"] == 931_081_858 + assert result["monthly_downloads"] == 16_475_574 + assert result["daily_downloads"] == 583_621 + assert result["dependent_packages_count"] == 5929 + assert result["favers"] == 8707 + + def test_weekly_downloads_is_monthly_divided_by_four(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._make_packagist_response() + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + assert result["weekly_downloads"] == 16_475_574 // 4 + + def test_weekly_downloads_none_when_monthly_absent(self): + resp = self._make_packagist_response() + del resp["package"]["downloads"]["monthly"] + mock_resp = MagicMock() + mock_resp.json.return_value = resp + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + assert result["weekly_downloads"] is None + + def test_description_truncated_to_120_chars(self): + resp = self._make_packagist_response() + resp["package"]["description"] = "x" * 200 + mock_resp = MagicMock() + mock_resp.json.return_value = resp + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + assert len(result["description"]) == 120 + + def test_first_release_date_and_age_years(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._make_packagist_response() + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + assert result["first_release_date"] == "2011-10-16T03:42:31+00:00" + assert isinstance(result["age_years"], float) + assert result["age_years"] > 10.0 # symfony/http-foundation is 14+ years old + + def test_latest_stable_version_excludes_dev(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._make_packagist_response() + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + # v7.3.0 is the latest stable (v7.4.x-dev skipped) + assert result.get("latest_version") == "v7.3.0" + + def test_total_versions_count(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._make_packagist_response() + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + assert result["total_versions"] == 3 + + def test_home_page_set_from_repository(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._make_packagist_response() + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + assert result["home_page"] == "https://github.com/symfony/http-foundation" + + def test_graceful_degradation_on_http_error(self): + with patch("requests.get", side_effect=Exception("404 Not Found")): + result = _enrich_packagist("nonexistent/package") + assert result["ecosystem"] == "Packagist" + assert result["package_name"] == "nonexistent/package" + # No stats keys should be present on total failure + assert "total_downloads" not in result + + def test_blast_score_critical_for_large_monthly_downloads(self): + """5M+ monthly => weekly proxy >=1.25M => CRITICAL.""" + resp = self._make_packagist_response() + resp["package"]["downloads"]["monthly"] = 20_000_000 + mock_resp = MagicMock() + mock_resp.json.return_value = resp + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("symfony/http-foundation") + from manus_agent.tools.get_dependency_blast_radius import _blast_score + + assert _blast_score(result) == "CRITICAL" + + def test_blast_score_uses_dependent_packages_count(self): + """50,000+ dependents => CRITICAL even with low download count.""" + resp = self._make_packagist_response() + resp["package"]["downloads"] = {"total": 100, "monthly": 0, "daily": 0} + resp["package"]["dependents"] = 60_000 + mock_resp = MagicMock() + mock_resp.json.return_value = resp + with patch("requests.get", return_value=mock_resp): + result = _enrich_packagist("laravel/framework") + from manus_agent.tools.get_dependency_blast_radius import _blast_score + + assert _blast_score(result) == "CRITICAL" + + def test_full_output_contains_packagist_fields(self): + """Integration: blast-radius output includes PHP-specific lines.""" + mock_resp = MagicMock() + mock_resp.json.return_value = self._make_packagist_response() + with patch("manus_agent.tools.get_dependency_blast_radius._fetch_nvd_affected", return_value=[]): + with patch("manus_agent.tools.get_dependency_blast_radius._fetch_osv_affected", return_value=[]): + with patch("manus_agent.tools.get_dependency_blast_radius._fetch_ghsa_affected", return_value=[]): + with patch("requests.get", return_value=mock_resp): + output = get_dependency_blast_radius("packagist:symfony/http-foundation@7.3.0") + assert "Total downloads" in output + assert "PHP dependents" in output + assert "Packagist favers" in output + assert "First released" in output + + class TestEnrichPackageDispatch: def test_npm_ecosystem(self): with patch("manus_agent.tools.get_dependency_blast_radius._enrich_npm") as mock_npm: @@ -677,6 +910,24 @@ def test_unknown_ecosystem_returns_minimal_record(self): assert result["ecosystem"] == "SomeExoticEcosystem" assert result["package_name"] == "unknown-pkg" + def test_packagist_ecosystem(self): + with patch("manus_agent.tools.get_dependency_blast_radius._enrich_packagist") as mock_packagist: + mock_packagist.return_value = {"ecosystem": "Packagist", "package_name": "symfony/http-foundation"} + _enrich_package("symfony/http-foundation", "Packagist") + mock_packagist.assert_called_once_with("symfony/http-foundation") + + def test_php_ecosystem_routes_to_packagist(self): + with patch("manus_agent.tools.get_dependency_blast_radius._enrich_packagist") as mock_packagist: + mock_packagist.return_value = {"ecosystem": "Packagist", "package_name": "laravel/framework"} + _enrich_package("laravel/framework", "php") + mock_packagist.assert_called_once_with("laravel/framework") + + def test_composer_ecosystem_routes_to_packagist(self): + with patch("manus_agent.tools.get_dependency_blast_radius._enrich_packagist") as mock_packagist: + mock_packagist.return_value = {"ecosystem": "Packagist", "package_name": "guzzlehttp/guzzle"} + _enrich_package("guzzlehttp/guzzle", "composer") + mock_packagist.assert_called_once_with("guzzlehttp/guzzle") + # =========================================================================== # get_dependency_blast_radius (integration tests with full stack mocked)