169 lines
5.4 KiB
Python
Executable File
169 lines
5.4 KiB
Python
Executable File
#!/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())
|