Monorepo Analyzer

v1.0.0

Analyze monorepo structure — detect workspace tools (npm/yarn/pnpm/lerna/nx/turbo/cargo/go), map inter-package dependencies, find unused packages, detect ver...

0· 52· 1 versions· 0 current· 0 all-time· Updated 1d ago· MIT-0

Monorepo Analyzer

Understand and audit monorepo structure. Detects workspace configuration, maps package dependencies, finds problems (circular deps, version mismatches, unused packages), and computes optimal build order.

Use when: "analyze this monorepo", "show package dependencies", "find unused packages", "check for circular deps", "what's the build order", "monorepo health check", or when onboarding to a large monorepo.

Commands

1. discover — Detect Monorepo Configuration

Identify the workspace tool and enumerate all packages.

# Detect workspace tool
echo "Checking workspace configuration..."

# npm workspaces (package.json)
if [ -f "package.json" ]; then
  python3 -c "
import json, glob
d = json.load(open('package.json'))
ws = d.get('workspaces', [])
if isinstance(ws, dict): ws = ws.get('packages', [])
if ws:
    print('Tool: npm workspaces')
    print(f'Workspace globs: {ws}')
    for pattern in ws:
        for p in glob.glob(pattern + '/package.json'):
            name = json.load(open(p)).get('name', p)
            print(f'  Package: {name} ({p})')
" 2>/dev/null
fi

# pnpm workspaces
if [ -f "pnpm-workspace.yaml" ]; then
  echo "Tool: pnpm workspaces"
  cat pnpm-workspace.yaml
fi

# Yarn workspaces (package.json or .yarnrc.yml)
if [ -f ".yarnrc.yml" ]; then
  echo "Tool: Yarn (Berry)"
  grep "nodeLinker" .yarnrc.yml 2>/dev/null
fi

# Lerna
if [ -f "lerna.json" ]; then
  echo "Tool: Lerna"
  cat lerna.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'Version: {d.get(\"version\")}, Packages: {d.get(\"packages\")}')" 2>/dev/null
fi

# Nx
if [ -f "nx.json" ]; then
  echo "Tool: Nx"
  cat nx.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'Affected default base: {d.get(\"affected\",{}).get(\"defaultBase\",\"main\")}')" 2>/dev/null
fi

# Turborepo
if [ -f "turbo.json" ]; then
  echo "Tool: Turborepo"
  cat turbo.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'Pipeline tasks: {list(d.get(\"pipeline\",d.get(\"tasks\",{})).keys())}')" 2>/dev/null
fi

# Cargo workspaces (Rust)
if [ -f "Cargo.toml" ]; then
  grep -A20 "\[workspace\]" Cargo.toml 2>/dev/null && echo "Tool: Cargo workspace"
fi

# Go workspaces
if [ -f "go.work" ]; then
  echo "Tool: Go workspace"
  cat go.work
fi

Output: workspace tool, total package count, package names and paths.

2. deps — Inter-Package Dependency Graph

Map which packages depend on which other packages within the monorepo.

# For JS/TS monorepos: extract internal dependencies
python3 -c "
import json, glob, os

# Collect all package names
packages = {}
for pj in glob.glob('**/package.json', recursive=True):
    if 'node_modules' in pj: continue
    try:
        d = json.load(open(pj))
        name = d.get('name')
        if name:
            packages[name] = {
                'path': os.path.dirname(pj),
                'deps': list(d.get('dependencies', {}).keys()),
                'devDeps': list(d.get('devDependencies', {}).keys()),
                'peerDeps': list(d.get('peerDependencies', {}).keys())
            }
    except: pass

# Filter to internal deps only
internal_names = set(packages.keys())
print(f'Total packages: {len(packages)}')
print()
for name, info in sorted(packages.items()):
    internal_deps = [d for d in info['deps'] if d in internal_names]
    internal_dev = [d for d in info['devDeps'] if d in internal_names]
    internal_peer = [d for d in info['peerDeps'] if d in internal_names]
    if internal_deps or internal_dev or internal_peer:
        print(f'{name}:')
        for d in internal_deps: print(f'  → {d} (dependency)')
        for d in internal_dev: print(f'  → {d} (devDependency)')
        for d in internal_peer: print(f'  → {d} (peerDependency)')
    else:
        print(f'{name}: (no internal deps — leaf package)')
" 2>/dev/null

For Cargo/Go workspaces, parse respective config files similarly.

Generate a Mermaid dependency diagram:

graph LR
  A[app] --> B[ui-lib]
  A --> C[api-client]
  B --> D[utils]
  C --> D

3. circular — Detect Circular Dependencies

python3 -c "
import json, glob, os

packages = {}
for pj in glob.glob('**/package.json', recursive=True):
    if 'node_modules' in pj: continue
    try:
        d = json.load(open(pj))
        name = d.get('name')
        if name:
            all_deps = set(d.get('dependencies', {}).keys()) | set(d.get('devDependencies', {}).keys())
            packages[name] = all_deps
    except: pass

internal = set(packages.keys())

# DFS cycle detection
def find_cycles(graph, internal):
    cycles = []
    visited = set()
    path = []
    path_set = set()

    def dfs(node):
        if node in path_set:
            cycle_start = path.index(node)
            cycles.append(path[cycle_start:] + [node])
            return
        if node in visited:
            return
        visited.add(node)
        path.append(node)
        path_set.add(node)
        for dep in graph.get(node, set()):
            if dep in internal:
                dfs(dep)
        path.pop()
        path_set.discard(node)

    for node in graph:
        if node in internal:
            dfs(node)
    return cycles

cycles = find_cycles(packages, internal)
if cycles:
    print(f'⚠️  Found {len(cycles)} circular dependency chain(s):')
    for c in cycles:
        print(f'  {\" → \".join(c)}')
else:
    print('✅ No circular dependencies found')
" 2>/dev/null

4. versions — Version Consistency Check

Find cases where different packages specify different versions of the same external dependency.

python3 -c "
import json, glob
from collections import defaultdict

dep_versions = defaultdict(dict)

for pj in glob.glob('**/package.json', recursive=True):
    if 'node_modules' in pj: continue
    try:
        d = json.load(open(pj))
        name = d.get('name', pj)
        for dep_type in ['dependencies', 'devDependencies']:
            for dep, ver in d.get(dep_type, {}).items():
                dep_versions[dep][name] = ver
    except: pass

# Find inconsistencies
mismatches = {}
for dep, consumers in dep_versions.items():
    versions = set(consumers.values())
    if len(versions) > 1:
        mismatches[dep] = consumers

if mismatches:
    print(f'⚠️  Found {len(mismatches)} dependencies with version mismatches:')
    for dep, consumers in sorted(mismatches.items()):
        print(f'  {dep}:')
        for pkg, ver in sorted(consumers.items()):
            print(f'    {pkg}: {ver}')
else:
    print('✅ All shared dependencies use consistent versions')
" 2>/dev/null

5. unused — Find Unused Packages

Packages defined in the workspace but not depended on by any other package (and not the root app).

python3 -c "
import json, glob

packages = {}
all_internal_deps = set()

for pj in glob.glob('**/package.json', recursive=True):
    if 'node_modules' in pj: continue
    try:
        d = json.load(open(pj))
        name = d.get('name')
        if name:
            packages[name] = d
            for dt in ['dependencies', 'devDependencies', 'peerDependencies']:
                all_internal_deps.update(d.get(dt, {}).keys())
    except: pass

internal_names = set(packages.keys())
unused = internal_names - all_internal_deps

# Filter: packages with 'start' or 'serve' scripts are likely apps, not libraries
true_unused = []
for name in unused:
    scripts = packages[name].get('scripts', {})
    is_app = any(k in scripts for k in ['start', 'serve', 'dev'])
    if is_app:
        print(f'  {name} (app entry point — not counted as unused)')
    else:
        true_unused.append(name)

if true_unused:
    print(f'⚠️  {len(true_unused)} potentially unused packages:')
    for name in sorted(true_unused):
        print(f'  {name}')
else:
    print('✅ No unused packages found')
" 2>/dev/null

6. build-order — Compute Topological Build Order

python3 -c "
import json, glob
from collections import defaultdict, deque

packages = {}
for pj in glob.glob('**/package.json', recursive=True):
    if 'node_modules' in pj: continue
    try:
        d = json.load(open(pj))
        name = d.get('name')
        if name:
            deps = set(d.get('dependencies', {}).keys()) | set(d.get('devDependencies', {}).keys())
            packages[name] = deps
    except: pass

internal = set(packages.keys())

# Topological sort (Kahn's algorithm)
in_degree = defaultdict(int)
graph = defaultdict(list)
for name in internal:
    if name not in in_degree: in_degree[name] = 0
    for dep in packages.get(name, set()):
        if dep in internal:
            graph[dep].append(name)
            in_degree[name] += 1

queue = deque([n for n in internal if in_degree[n] == 0])
order = []
while queue:
    node = queue.popleft()
    order.append(node)
    for neighbor in graph[node]:
        in_degree[neighbor] -= 1
        if in_degree[neighbor] == 0:
            queue.append(neighbor)

if len(order) == len(internal):
    print('Build order (leaf dependencies first):')
    for i, name in enumerate(order, 1):
        print(f'  {i}. {name}')
else:
    print('⚠️  Cannot determine full build order — circular dependencies exist')
    print(f'  Ordered: {len(order)}/{len(internal)} packages')
" 2>/dev/null

7. stats — Monorepo Statistics

# Package count and sizes
echo "=== Package Stats ==="
find . -maxdepth 3 -name "package.json" -not -path '*/node_modules/*' 2>/dev/null | wc -l
echo "packages found"

# Lines of code per package
for pj in $(find . -maxdepth 3 -name "package.json" -not -path '*/node_modules/*' 2>/dev/null); do
  DIR=$(dirname "$pj")
  NAME=$(python3 -c "import json; print(json.load(open('$pj')).get('name','$DIR'))" 2>/dev/null)
  LOC=$(find "$DIR" -type f \( -name "*.ts" -o -name "*.js" -o -name "*.tsx" -o -name "*.jsx" \) \
    -not -path '*/node_modules/*' -not -path '*/dist/*' 2>/dev/null | xargs wc -l 2>/dev/null | tail -1 | awk '{print $1}')
  echo "  $NAME: ${LOC:-0} lines"
done

# Git activity per package (last 30 days)
echo "=== Recent Activity (30 days) ==="
for pj in $(find . -maxdepth 3 -name "package.json" -not -path '*/node_modules/*' 2>/dev/null); do
  DIR=$(dirname "$pj")
  NAME=$(python3 -c "import json; print(json.load(open('$pj')).get('name','$DIR'))" 2>/dev/null)
  COMMITS=$(git log --since="30 days ago" --oneline -- "$DIR" 2>/dev/null | wc -l)
  if [ "$COMMITS" -gt 0 ]; then
    echo "  $NAME: $COMMITS commits"
  fi
done

Output Formats

  • text (default): Human-readable report with sections
  • json: Machine-readable {tool, packages: [{name, path, deps, devDeps}], graph: {edges}, cycles: [], mismatches: {}, unused: []}
  • markdown: Wiki-ready document with Mermaid diagrams
  • mermaid: Pure Mermaid dependency graph

CI Integration

Exit codes:

  • 0: No issues found
  • 1: Circular dependencies detected
  • 2: Version mismatches exceed threshold (default: 5)
# GitHub Actions
- name: Check monorepo health
  run: |
    # Agent runs: monorepo-analyzer circular
    # Agent runs: monorepo-analyzer versions
    # Exits 1 if circular deps or too many mismatches

Notes

  • Supports JS/TS (npm/yarn/pnpm/lerna/nx/turbo), Rust (cargo), and Go (go.work) monorepos
  • Does not install dependencies — works from config files only
  • For very large monorepos (500+ packages), the full scan may take a minute
  • Build order assumes no circular deps — run circular first
  • Version consistency check covers external deps only — internal workspace deps use workspace:* protocol

Version tags

latestvk97cx705jwrv4ftdwtqe5xvyjn85n1yp