#!/usr/bin/env python3 """Detect monorepo tooling, workspaces, and internal dependency graph.""" from __future__ import annotations import argparse import glob import json import os from pathlib import Path from typing import Dict, List, Set def load_json(path: Path) -> Dict: try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} def detect_repo_type(root: Path) -> List[str]: detected: List[str] = [] if (root / "turbo.json").exists(): detected.append("Turborepo") if (root / "nx.json").exists(): detected.append("Nx") if (root / "pnpm-workspace.yaml").exists(): detected.append("pnpm-workspaces") if (root / "lerna.json").exists(): detected.append("Lerna") pkg = load_json(root / "package.json") if "workspaces" in pkg and "npm-workspaces" not in detected: detected.append("npm-workspaces") return detected def parse_pnpm_workspace(root: Path) -> List[str]: workspace_file = root / "pnpm-workspace.yaml" if not workspace_file.exists(): return [] patterns: List[str] = [] in_packages = False for line in workspace_file.read_text(encoding="utf-8", errors="ignore").splitlines(): stripped = line.strip() if stripped.startswith("packages:"): in_packages = True continue if in_packages and stripped.startswith("-"): item = stripped[1:].strip().strip('"').strip("'") if item: patterns.append(item) elif in_packages and stripped and not stripped.startswith("#") and not stripped.startswith("-"): in_packages = False return patterns def parse_package_workspaces(root: Path) -> List[str]: pkg = load_json(root / "package.json") workspaces = pkg.get("workspaces") if isinstance(workspaces, list): return [str(item) for item in workspaces] if isinstance(workspaces, dict) and isinstance(workspaces.get("packages"), list): return [str(item) for item in workspaces["packages"]] return [] def expand_workspace_patterns(root: Path, patterns: List[str]) -> List[Path]: paths: Set[Path] = set() for pattern in patterns: for match in glob.glob(str(root / pattern)): p = Path(match) if p.is_dir() and (p / "package.json").exists(): paths.add(p.resolve()) return sorted(paths) def load_workspace_packages(workspaces: List[Path]) -> Dict[str, Dict]: packages: Dict[str, Dict] = {} for ws in workspaces: data = load_json(ws / "package.json") name = data.get("name") or ws.name packages[name] = { "path": str(ws), "dependencies": data.get("dependencies", {}), "devDependencies": data.get("devDependencies", {}), "peerDependencies": data.get("peerDependencies", {}), } return packages def build_dependency_graph(packages: Dict[str, Dict]) -> Dict[str, List[str]]: package_names = set(packages.keys()) graph: Dict[str, List[str]] = {} for name, meta in packages.items(): deps: Set[str] = set() for section in ("dependencies", "devDependencies", "peerDependencies"): dep_map = meta.get(section, {}) if isinstance(dep_map, dict): for dep_name in dep_map.keys(): if dep_name in package_names: deps.add(dep_name) graph[name] = sorted(deps) return graph def format_tree_paths(root: Path, workspaces: List[Path]) -> List[str]: out: List[str] = [] for ws in workspaces: out.append(str(ws.relative_to(root))) return out def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Analyze monorepo type, workspaces, and internal dependency graph.") parser.add_argument("path", help="Monorepo root path") parser.add_argument("--json", action="store_true", help="Output JSON") return parser.parse_args() def main() -> int: args = parse_args() root = Path(args.path).expanduser().resolve() if not root.exists() or not root.is_dir(): raise SystemExit(f"Path is not a directory: {root}") types = detect_repo_type(root) patterns = parse_pnpm_workspace(root) if not patterns: patterns = parse_package_workspaces(root) workspaces = expand_workspace_patterns(root, patterns) packages = load_workspace_packages(workspaces) graph = build_dependency_graph(packages) report = { "root": str(root), "detected_types": types, "workspace_patterns": patterns, "workspace_paths": format_tree_paths(root, workspaces), "package_count": len(packages), "dependency_graph": graph, } if args.json: print(json.dumps(report, indent=2)) else: print("Monorepo Analysis") print(f"Root: {report['root']}") print(f"Detected: {', '.join(types) if types else 'none'}") print(f"Workspace patterns: {', '.join(patterns) if patterns else 'none'}") print("") print("Workspaces") for ws in report["workspace_paths"]: print(f"- {ws}") if not report["workspace_paths"]: print("- none detected") print("") print("Internal dependency graph") for pkg, deps in graph.items(): print(f"- {pkg} -> {', '.join(deps) if deps else '(no internal deps)'}") return 0 if __name__ == "__main__": raise SystemExit(main())