Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to DepWatch will be documented in this file.

Format follows [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

### Added
- Transitive Dependency Analysis (MVP)
- Recursive resolution of nested dependencies via PyPI metadata
- Cycle detection and depth limiting to prevent runaway scans
- New `--transitive` / `-t` flag for the `scan` command
- New `--depth` / `-d` option to control recursion depth
- Enhanced CLI output with `[direct]` vs `[transitive]` labels
- Visible dependency paths for nested packages (e.g., `pkg-a → pkg-b → risky-pkg`)
- Integration of transitive analysis into the FastAPI `/scan` endpoint

## [0.1.0] — 2026-05-06

### Added
Expand Down
8 changes: 0 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,6 @@ ruff format .
5. Commit with a clear message (`git commit -m "feat: add X"`)
6. Push and open a Pull Request

## Commit Style

We use conventional commits:
- `feat:` — new feature
- `fix:` — bug fix
- `docs:` — documentation only
- `chore:` — maintenance / tooling
- `refactor:` — code restructuring

## Code of Conduct

Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ DepWatch scans a GitHub repository, extracts its dependencies, and delivers a tr

- **Multi-signal analysis** — commits, releases, contributors, and issue activity
- **Risk score (0–10)** — quantifiable health metric for every dependency
- **Transitive analysis** — recursively scan nested dependencies (MVP)
- **Confidence levels** — High / Medium / Low based on signal agreement
- **Actionable recommendations** — clear guidance on what to do next
- **Rich CLI output** — color-coded panels with detailed breakdowns
Expand Down Expand Up @@ -47,6 +48,14 @@ This creates `dist/dep_watch-X.Y.Z.tar.gz` and `dist/dep_watch-X.Y.Z-py3-none-an
depwatch scan https://github.com/fastapi/fastapi
```

### Transitive Dependencies

Analyze nested dependencies with depth control:

```bash
depwatch scan https://github.com/fastapi/fastapi --transitive --depth 2
```

### GitHub Token (Recommended)

Set a token to avoid rate limits:
Expand All @@ -70,24 +79,35 @@ uvicorn app.main:app --reload
## Sample Output

```
📦 Found 5 dependencies. Analyzing health...

🟢 5 healthy
📦 Found 2 direct and 3 transitive dependencies. Analyzing health...

╭─────────── pydantic ────────────╮
│ Status: Healthy │
│ Type: [direct] │
│ Risk Score: 0/10 │
│ Confidence: High │
│ │
│ Signals: │
│ • Last commit 0 days ago │
│ • Last release 15 days ago │
│ • Contributor count: 100 │
│ • Open issues: 560 │
│ • 100 issues updated recently │
│ │
│ Action: No action needed │
╰─────────────────────────────────╯

╭───────── some-nested-pkg ─────────╮
│ Status: Risky │
│ Type: [transitive] │
│ Path: fastapi → pydantic → pkg │
│ Risk Score: 8/10 │
│ Confidence: High │
│ │
│ Signals: │
│ • Last commit 400 days ago │
│ • No official releases found │
│ │
│ Action: Consider replacing this │
╰──────────────────────────────────╯
```

## How Scoring Works
Expand Down
31 changes: 27 additions & 4 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
from pydantic import BaseModel, HttpUrl

from app.github import GitHubClient
from app.pypi import PyPIClient
from app.scoring import HealthStatus, ScoringEngine
from app.services import DependencyAnalyzer, DependencyScanner
from app.services import (
DependencyAnalyzer,
DependencyNode,
DependencyScanner,
TransitiveDependencyResolver,
)

app = FastAPI(
title="DepWatch",
Expand All @@ -16,6 +22,8 @@

class ScanRequest(BaseModel):
repo_url: HttpUrl
transitive: bool = False
depth: int = 3


class DependencyReport(BaseModel):
Expand All @@ -26,6 +34,8 @@ class DependencyReport(BaseModel):
signals: List[str]
recommendation: str
repo_url: Optional[str] = None
is_direct: bool = True
dependency_path: Optional[str] = None


class ScanResponse(BaseModel):
Expand Down Expand Up @@ -61,8 +71,17 @@ async def scan_repository(request: ScanRequest):
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error fetching dependencies: {e}")

all_nodes = []
if request.transitive:
pypi_client = PyPIClient()
resolver = TransitiveDependencyResolver(pypi_client, max_depth=request.depth)
all_nodes = await resolver.resolve(dependencies)
else:
all_nodes = [DependencyNode(name=d) for d in dependencies]

reports = []
for dep_name in dependencies:
for node in all_nodes:
dep_name = node.name
try:
signals = await analyzer.analyze(dep_name)
review = engine.classify(signals)
Expand All @@ -74,7 +93,9 @@ async def scan_repository(request: ScanRequest):
confidence=review.confidence,
signals=review.signals,
recommendation=review.recommendation,
repo_url=signals.repo_url
repo_url=signals.repo_url,
is_direct=node.is_direct,
dependency_path=node.dependency_path if not node.is_direct else None,
)
)
except Exception:
Expand All @@ -85,7 +106,9 @@ async def scan_repository(request: ScanRequest):
risk_score=0,
confidence="Low",
signals=["Analysis failed"],
recommendation="Retry later"
recommendation="Retry later",
is_direct=node.is_direct,
dependency_path=node.dependency_path if not node.is_direct else None,
)
)

Expand Down
3 changes: 3 additions & 0 deletions app/pypi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from app.pypi.client import PyPIClient

__all__ = ["PyPIClient"]
73 changes: 73 additions & 0 deletions app/pypi/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""PyPI JSON API client for resolving transitive dependencies."""

import re
from typing import Optional

import httpx


class PyPIClient:
"""Async client for the PyPI JSON API with in-memory caching."""

BASE_URL = "https://pypi.org"

def __init__(self) -> None:
self._cache: dict[str, list[str]] = {}

@staticmethod
def _normalize_name(name: str) -> str:
"""Normalize a package name per PEP 503 (lowercase, hyphens → dashes)."""
return re.sub(r"[-_.]+", "-", name).lower()

@staticmethod
def _parse_dep_name(dep_string: str) -> Optional[str]:
"""Extract the clean package name from a PEP 508 dependency string.

Examples:
'requests>=2.0' → 'requests'
'urllib3[socks]!=1.25.0' → 'urllib3'
'foo ; python_version<"3"' → 'foo'
'bar (>=1.0)' → 'bar'
"""
# Strip environment markers (everything after ';')
dep_string = dep_string.split(";")[0].strip()
# Extract the package name (before any extras, version specifiers, or parens)
match = re.match(r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)", dep_string)
return match.group(1) if match else None

async def get_dependencies(self, package_name: str) -> list[str]:
"""Fetch the runtime dependencies of a package from PyPI.

Returns a list of normalized dependency package names.
Results are cached in-memory for the lifetime of this client.
"""
normalized = self._normalize_name(package_name)

if normalized in self._cache:
return self._cache[normalized]

deps: list[str] = []
try:
async with httpx.AsyncClient(base_url=self.BASE_URL, timeout=15.0) as client:
response = await client.get(f"/pypi/{normalized}/json")
if response.status_code != 200:
self._cache[normalized] = []
return []

data = response.json()
requires_dist: list[str] = data.get("info", {}).get("requires_dist") or []

for dep_str in requires_dist:
# Skip dependencies with 'extra ==' markers (optional extras)
if "extra ==" in dep_str or "extra==" in dep_str:
continue
name = self._parse_dep_name(dep_str)
if name:
deps.append(self._normalize_name(name))

except (httpx.HTTPError, Exception):
# Network errors, timeouts, JSON parse errors — fail gracefully
pass

self._cache[normalized] = deps
return deps
17 changes: 12 additions & 5 deletions app/scoring/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def classify(signals: DependencySignals) -> HealthReview:
status=HealthStatus.UNKNOWN,
reason="Repository not found",
confidence="Low",
recommendation="Verify repository URL"
recommendation="Verify repository URL",
)

now = datetime.now(timezone.utc)
Expand Down Expand Up @@ -86,10 +86,17 @@ def classify(signals: DependencySignals) -> HealthReview:
healthy_contributors = signals.contributor_count >= 5

# Sum them up
strong_signals = sum([
no_commits_90d, no_release_120d, low_contributors, stagnant_issues,
recent_commit_30d, recent_release_60d, healthy_contributors
])
strong_signals = sum(
[
no_commits_90d,
no_release_120d,
low_contributors,
stagnant_issues,
recent_commit_30d,
recent_release_60d,
healthy_contributors,
]
)

if strong_signals >= 3:
review.confidence = "High"
Expand Down
9 changes: 8 additions & 1 deletion app/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from app.services.analyzer import DependencyAnalyzer
from app.services.models import DependencyNode
from app.services.resolver import TransitiveDependencyResolver
from app.services.scanner import DependencyScanner

__all__ = ["DependencyScanner", "DependencyAnalyzer"]
__all__ = [
"DependencyScanner",
"DependencyAnalyzer",
"DependencyNode",
"TransitiveDependencyResolver",
]
34 changes: 34 additions & 0 deletions app/services/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Shared data models for dependency tree representation."""

from __future__ import annotations

from dataclasses import dataclass, field


@dataclass
class DependencyNode:
"""Represents a single dependency in the resolved tree.

Attributes:
name: Normalized package name.
depth: Distance from the root (0 = direct dependency).
parent_chain: Ordered list of ancestor names from root to this node.
e.g. ['fastapi', 'starlette'] means fastapi → starlette → this.
is_direct: True if this is a direct (top-level) dependency.
children: Names of this node's direct sub-dependencies.
"""

name: str
depth: int = 0
parent_chain: list[str] = field(default_factory=list)
is_direct: bool = True
children: list[str] = field(default_factory=list)

@property
def dependency_path(self) -> str:
"""Human-readable dependency path string.

Example: 'fastapi → starlette → anyio'
"""
chain = [*self.parent_chain, self.name]
return " → ".join(chain)
67 changes: 67 additions & 0 deletions app/services/resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Recursive dependency tree resolver using PyPI metadata."""

import collections
from typing import List

from app.pypi.client import PyPIClient
from app.services.models import DependencyNode


class TransitiveDependencyResolver:
"""Resolves a complete dependency tree from a list of direct dependencies."""

def __init__(self, pypi_client: PyPIClient, max_depth: int = 3):
self.pypi_client = pypi_client
self.max_depth = max_depth

async def resolve(self, direct_deps: List[str]) -> List[DependencyNode]:
"""Resolve all transitive dependencies using BFS traversal.

Args:
direct_deps: List of top-level package names.

Returns:
A flat list of all unique DependencyNode objects (direct + transitive).
"""
# Flat list of results
resolved_nodes: List[DependencyNode] = []

# Tracking visited packages to prevent cycles and redundant network calls
# Maps package_name -> depth at which it was first found
visited = {}

# BFS queue: (package_name, depth, parent_chain)
queue = collections.deque()

# Add direct dependencies to queue
for dep in direct_deps:
norm_name = self.pypi_client._normalize_name(dep)
if norm_name not in visited:
visited[norm_name] = 0
queue.append((norm_name, 0, []))

while queue:
# Current batch processing for concurrency (optional, but good for speed)
# Process one "level" at a time or just go one-by-one.
# To keep it simple for MVP, we'll go one by one but we could batch them.
name, depth, parent_chain = queue.popleft()

# Create the node
node = DependencyNode(
name=name, depth=depth, parent_chain=parent_chain, is_direct=(depth == 0)
)

# If we haven't reached max depth, fetch children
if depth < self.max_depth:
children_names = await self.pypi_client.get_dependencies(name)
node.children = children_names

# Prepare children for queue
for child in children_names:
if child not in visited:
visited[child] = depth + 1
queue.append((child, depth + 1, parent_chain + [name]))

resolved_nodes.append(node)

return resolved_nodes
Loading
Loading