Analyzes project dependencies for:
- Dependency tree (direct and transitive)
- Circular dependencies between modules
- Outdated packages (basic detection)
- npm/yarn (package.json)
- Python (requirements.txt, pyproject.toml)
from typing import Dict, List, Set, Tuple, Optional
from collections import defaultdict
class DependencyAnalyzer:
"""Analyzes project dependencies and module coupling."""
def __init__(self, project_path: Path, verbose: bool = False):
self.project_path = project_path
self.direct_deps: Dict[str, str] = {} # name -> version
self.dev_deps: Dict[str, str] = {}
self.internal_modules: Dict[str, Set[str]] = defaultdict(set) # module -> imports
self.circular_deps: List[List[str]] = []
self.coupling_score: float = 0
self.issues: List[Dict] = []
self.recommendations: List[str] = []
self.package_manager: Optional[str] = None
def analyze(self) -> Dict:
"""Run full dependency analysis."""
self._detect_package_manager()
self._parse_dependencies()
self._scan_internal_modules()
self._detect_circular_dependencies()
self._calculate_coupling_score()
self._generate_recommendations()
return self._build_report()
def _detect_package_manager(self):
"""Detect which package manager is used."""
if (self.project_path / 'package.json').exists():
self.package_manager = 'npm'
elif (self.project_path / 'requirements.txt').exists():
self.package_manager = 'pip'
elif (self.project_path / 'pyproject.toml').exists():
self.package_manager = 'poetry'
elif (self.project_path / 'go.mod').exists():
self.package_manager = 'go'
elif (self.project_path / 'Cargo.toml').exists():
self.package_manager = 'cargo'
self.package_manager = 'unknown'
print(f"Detected package manager: {self.package_manager}")
def _parse_dependencies(self):
"""Parse dependencies based on detected package manager."""
'poetry': self._parse_poetry,
'cargo': self._parse_cargo,
parser = parsers.get(self.package_manager)
"""Parse package.json for npm dependencies."""
pkg_path = self.project_path / 'package.json'
data = json.loads(pkg_path.read_text())
for name, version in data.get('dependencies', {}).items():
self.direct_deps[name] = self._clean_version(version)
for name, version in data.get('devDependencies', {}).items():
self.dev_deps[name] = self._clean_version(version)
print(f"Found {len(self.direct_deps)} direct deps, "
f"{len(self.dev_deps)} dev deps")
'message': f"Failed to parse package.json: {e}"
"""Parse requirements.txt for Python dependencies."""
req_path = self.project_path / 'requirements.txt'
content = req_path.read_text()
for line in content.strip().split('\n'):
if not line or line.startswith('#') or line.startswith('-'):
match = re.match(r'^([a-zA-Z0-9_-]+)(?:[=<>!~]+(.+))?', line)
version = match.group(2) or 'any'
self.direct_deps[name] = version
print(f"Found {len(self.direct_deps)} dependencies")
'message': f"Failed to parse requirements.txt: {e}"
"""Parse pyproject.toml for Poetry dependencies."""
toml_path = self.project_path / 'pyproject.toml'
content = toml_path.read_text()
# Simple TOML parsing for dependencies section
for line in content.split('\n'):
if line == '[tool.poetry.dependencies]':
elif line == '[tool.poetry.dev-dependencies]' or \
line == '[tool.poetry.group.dev.dependencies]':
elif line.startswith('['):
if (in_deps or in_dev_deps) and '=' in line:
match = re.match(r'^([a-zA-Z0-9_-]+)\s*=\s*["\']?([^"\']+)', line)
self.direct_deps[name] = version
self.dev_deps[name] = version
print(f"Found {len(self.direct_deps)} direct deps, "
f"{len(self.dev_deps)} dev deps")
'message': f"Failed to parse pyproject.toml: {e}"
"""Parse go.mod for Go dependencies."""
mod_path = self.project_path / 'go.mod'
content = mod_path.read_text()
for line in content.split('\n'):
if line.startswith('require ('):
elif line == ')' and in_require:
elif line.startswith('require ') and '(' not in line:
match = re.match(r'require\s+([^\s]+)\s+([^\s]+)', line)
self.direct_deps[match.group(1)] = match.group(2)
match = re.match(r'([^\s]+)\s+([^\s]+)', line)
self.direct_deps[match.group(1)] = match.group(2)
print(f"Found {len(self.direct_deps)} dependencies")
'message': f"Failed to parse go.mod: {e}"
"""Parse Cargo.toml for Rust dependencies."""
cargo_path = self.project_path / 'Cargo.toml'
content = cargo_path.read_text()
for line in content.split('\n'):
if line == '[dependencies]':
elif line == '[dev-dependencies]':
elif line.startswith('['):
if (in_deps or in_dev_deps) and '=' in line:
match = re.match(r'^([a-zA-Z0-9_-]+)\s*=\s*["\']?([^"\']+)', line)
self.direct_deps[name] = version
self.dev_deps[name] = version
print(f"Found {len(self.direct_deps)} direct deps, "
f"{len(self.dev_deps)} dev deps")
'message': f"Failed to parse Cargo.toml: {e}"
def _clean_version(self, version: str) -> str:
"""Clean version string."""
return version.lstrip('^~>=<!')
def _scan_internal_modules(self):
"""Scan internal module imports for coupling analysis."""
ignore_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv',
'dist', 'build', '.next', 'coverage'}
extensions = ['.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs']
for file_path in self.project_path.rglob(f'*{ext}'):
# Skip ignored directories
if any(ignored in file_path.parts for ignored in ignore_dirs):
# Get module name (directory relative to project root)
rel_path = file_path.relative_to(self.project_path)
module = rel_path.parts[0] if len(rel_path.parts) > 1 else 'root'
imports = self._extract_imports(file_path)
self.internal_modules[module].update(imports)
print(f"Scanned {len(self.internal_modules)} internal modules")
def _extract_imports(self, file_path: Path) -> Set[str]:
"""Extract import statements from a file."""
content = file_path.read_text(encoding='utf-8', errors='ignore')
for match in re.finditer(r'^(?:from|import)\s+([\w.]+)', content, re.MULTILINE):
imports.add(match.group(1).split('.')[0])
for match in re.finditer(r'(?:import|require)\s*\(?[\'"]([^\'"\s]+)[\'"]', content):
if imp.startswith('.') or imp.startswith('@/') or imp.startswith('~/'):
# Relative import - extract first path component
parts = imp.lstrip('./~@').split('/')
def _detect_circular_dependencies(self):
"""Detect circular dependencies between internal modules."""
modules = set(self.internal_modules.keys())
for module, imports in self.internal_modules.items():
# Check if import is an internal module
for internal_module in modules:
if internal_module.lower() in imp.lower() and internal_module != module:
graph[module].add(internal_module)
def find_cycles(node: str, path: List[str]):
for neighbor in graph.get(node, []):
if neighbor not in visited:
find_cycles(neighbor, path)
elif neighbor in rec_stack:
cycle_start = path.index(neighbor)
cycle = path[cycle_start:] + [neighbor]
if module not in visited:
self.circular_deps = cycles
'type': 'circular_dependency',
'message': f"Circular dependency: {' -> '.join(cycle)}"
print(f"Found {len(self.circular_deps)} circular dependencies")
def _calculate_coupling_score(self):
"""Calculate coupling score (0-100, lower is better)."""
if not self.internal_modules:
# Count connections between modules
total_modules = len(self.internal_modules)
modules = set(self.internal_modules.keys())
for module, imports in self.internal_modules.items():
for internal_module in modules:
if internal_module.lower() in imp.lower() and internal_module != module:
# Max possible connections (complete graph)
max_connections = total_modules * (total_modules - 1) if total_modules > 1 else 1
# Coupling score as percentage of max connections
self.coupling_score = min(100, int((total_connections / max_connections) * 100))
# Add penalty for circular dependencies
self.coupling_score = min(100, self.coupling_score + len(self.circular_deps) * 10)
print(f"Coupling score: {self.coupling_score}/100")
def _generate_recommendations(self):
"""Generate actionable recommendations."""
# Circular dependency recommendations
self.recommendations.append(
"Extract shared interfaces or create a common module to break circular dependencies"
# High coupling recommendations
if self.coupling_score > 70:
self.recommendations.append(
"High coupling detected. Consider applying SOLID principles and "
"introducing abstraction layers"
if len(self.direct_deps) > 50:
self.recommendations.append(
f"Large dependency count ({len(self.direct_deps)}). "
"Review for unused dependencies and consider bundle size impact"
# Check for known problematic packages (simplified check)
'lodash': 'Consider lodash-es or native methods for smaller bundle',
'moment': 'Consider day.js or date-fns for smaller bundle',
'request': 'Deprecated. Use axios, node-fetch, or native fetch',
for pkg, suggestion in problematic.items():
if pkg in self.direct_deps:
self.recommendations.append(f"{pkg}: {suggestion}")
def _build_report(self) -> Dict:
"""Build the analysis report."""
'project_path': str(self.project_path),
'package_manager': self.package_manager,
'direct_dependencies': len(self.direct_deps),
'dev_dependencies': len(self.dev_deps),
'internal_modules': len(self.internal_modules),
'coupling_score': self.coupling_score,
'circular_dependencies': len(self.circular_deps),
'issues': len(self.issues),
'direct': self.direct_deps,
'internal_modules': {k: list(v) for k, v in self.internal_modules.items()},
'circular_dependencies': self.circular_deps,
'recommendations': self.recommendations,
def print_human_report(report: Dict):
"""Print human-readable report."""
print("DEPENDENCY ANALYSIS REPORT")
print(f"\nProject: {report['project_path']}")
print(f"Package Manager: {report['package_manager']}")
summary = report['summary']
print("\n--- Summary ---")
print(f"Direct dependencies: {summary['direct_dependencies']}")
print(f"Dev dependencies: {summary['dev_dependencies']}")
print(f"Internal modules: {summary['internal_modules']}")
print(f"Coupling score: {summary['coupling_score']}/100 ", end='')
if summary['coupling_score'] < 30:
elif summary['coupling_score'] < 70:
print("(high - consider refactoring)")
if report['circular_dependencies']:
print(f"\n--- Circular Dependencies ({len(report['circular_dependencies'])}) ---")
for cycle in report['circular_dependencies']:
print(f" {' -> '.join(cycle)}")
print(f"\n--- Issues ({len(report['issues'])}) ---")
for issue in report['issues']:
severity = issue['severity'].upper()
print(f" [{severity}] {issue['message']}")
if report['recommendations']:
print(f"\n--- Recommendations ---")
for i, rec in enumerate(report['recommendations'], 1):
deps = report['dependencies']['direct']
print(f"\n--- Top Dependencies (of {len(deps)}) ---")
for name, version in list(deps.items())[:10]:
print(f" {name}: {version}")
print(f" ... and {len(deps) - 10} more")
parser = argparse.ArgumentParser(
description='Analyze project dependencies and module coupling',
formatter_class=argparse.RawDescriptionHelpFormatter,
%(prog)s ./my-project --output json
%(prog)s ./my-project --check circular
%(prog)s ./my-project --verbose
Supported package managers:
- npm/yarn (package.json)
- poetry (pyproject.toml)
help='Path to the project directory'
choices=['human', 'json'],
help='Output format (default: human)'
choices=['all', 'circular', 'coupling'],
help='What to check (default: all)'
help='Enable verbose output'
help='Save report to file'
args = parser.parse_args()
project_path = Path(args.project_path).resolve()
if not project_path.exists():
print(f"Error: Project path does not exist: {project_path}", file=sys.stderr)
if not project_path.is_dir():
print(f"Error: Project path is not a directory: {project_path}", file=sys.stderr)
analyzer = DependencyAnalyzer(project_path, verbose=args.verbose)
report = analyzer.analyze()
# Filter report based on --check option
if args.check == 'circular':
if report['circular_dependencies']:
print("Circular dependencies found:")
for cycle in report['circular_dependencies']:
print(f" {' -> '.join(cycle)}")
print("No circular dependencies found.")
elif args.check == 'coupling':
score = report['summary']['coupling_score']
print(f"Coupling score: {score}/100")
print("WARNING: High coupling detected")
if args.output == 'json':
output = json.dumps(report, indent=2)
Path(args.save).write_text(output)
print(f"Report saved to {args.save}")
print_human_report(report)
Path(args.save).write_text(json.dumps(report, indent=2))
print(f"\nJSON report saved to {args.save}")
if __name__ == '__main__':