Skip to content
Arxo Arxo

Monorepo Analysis

Arxo has native support for monorepo architectures, automatically detecting workspace configurations and analyzing package boundaries, blast radius, and cross-package coupling.

Arxo automatically detects and parses:

ToolDetectionPackage Discovery
pnpmpnpm-workspace.yamlWorkspace packages
npm workspacespackage.json workspaces fieldWorkspace packages
Yarn workspacespackage.json workspaces fieldWorkspace packages
Turborepoturbo.json + package managerPipeline + packages
CargoCargo.toml workspaceWorkspace members
NxDetected via npm/pnpm + nx.jsonProject configuration
  1. pnpm-workspace.yaml (highest priority)
  2. npm/yarn workspaces in root package.json
  3. turbo.json (marks workspace as Turborepo)
  4. Cargo.toml with [workspace]
  5. Single package (fallback)

Terminal window
# Automatic workspace detection
cd your-monorepo
arxo analyze --preset ci

Arxo will:

  • Detect workspace type (pnpm, Turbo, Cargo, etc.)
  • Load all package metadata
  • Analyze cross-package dependencies
  • Compute package-level metrics

For detailed monorepo-specific analysis:

Terminal window
arxo analyze --metric monorepo

Or add to .arxo.yaml:

metrics:
- id: monorepo
enabled: true

The monorepo metric provides package-level insights:

  1. Policy Violations - Direct and transitive package-dependency rule violations
  2. Blast Radius - How many packages are affected when one changes
  3. Build Alignment - Drift between static package deps and task-graph deps
  4. Affected-Set Quality - Precision/recall/F1 against historical co-change (git)
  5. Dependency Governance + Ownership - Version/workspace-protocol drift and bus-factor risk
monorepo.inventory.package_count: 24 # Total packages
monorepo.policy.edge_violations_count: 8 # Direct policy edge violations
monorepo.policy.transitive_violations_count: 3 # Transitive policy violations
monorepo.policy.compliance_ratio: 0.88 # 88% of evaluated policy edges comply
monorepo.structure.blast_radius.max: 15 # Worst-case blast radius
monorepo.structure.blast_radius.avg: 3.2 # Average packages affected per change
monorepo.build.missing_dependency_edges_count: 4 # Static deps missing in task graph

pnpm-workspace.yaml:

packages:
- 'packages/*'
- 'apps/*'

Arxo reads this file and:

  • Discovers all packages matching the globs
  • Parses each package.json for dependencies
  • Maps cross-package dependencies

package.json:

{
"workspaces": [
"packages/*",
"apps/*"
]
}

Same behavior as pnpm.

turbo.json:

{
"pipeline": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
}
}
}

Arxo reads:

  • Package manager config (pnpm/npm/yarn)
  • Turborepo pipeline for task dependencies

Cargo.toml:

[workspace]
members = [
"crates/*",
"packages/cli",
"packages/engine"
]

Arxo reads:

  • Workspace members from Cargo.toml
  • Dependencies from each crate’s Cargo.toml

Analyze at the package level:

data:
import_graph:
group_by: folder
group_depth: 2 # packages/feature-auth → one node

For structure like:

packages/
feature-auth/
feature-cart/
shared-ui/
apps/
web/
mobile/

group_depth: 2 groups by packages/feature-auth, apps/web, etc.

data:
import_graph:
exclude:
- "**/node_modules/**"
- "**/dist/**"
- "**/build/**"
data:
import_graph:
exclude:
- "packages/legacy/**" # Exclude old packages
- "apps/storybook/**" # Exclude non-production apps

Structure:

apps/
web/
mobile/
libs/
feature-auth/
feature-cart/
shared/ui/
shared/utils/

Config:

data:
import_graph:
group_by: folder
group_depth: 2 # Group by feature/app
exclude:
- "**/node_modules/**"
- "**/*.spec.ts"
metrics:
- id: monorepo
- id: scc
- id: propagation_cost
policy:
invariants:
# No cycles between features
- metric: scc.max_cycle_size
op: "=="
value: 0
# No direct policy violations
- metric: monorepo.policy.edge_violations_count
op: "=="
value: 0
# No transitive policy violations
- metric: monorepo.policy.transitive_violations_count
op: "=="
value: 0
# Limit blast radius
- metric: monorepo.structure.blast_radius.max
op: "<="
value: 10

Structure:

apps/
docs/
web/
api/
packages/
ui/
config/
tsconfig/

Config:

data:
import_graph:
group_by: folder
group_depth: 2
exclude:
- "**/node_modules/**"
- "**/.turbo/**"
metrics:
- id: monorepo
- id: propagation_cost
- id: centrality
policy:
invariants:
# No direct policy violations
- metric: monorepo.policy.edge_violations_count
op: "=="
value: 0
# Low coupling
- metric: propagation_cost.system.ratio
op: "<="
value: 0.15
# No transitive policy violations
- metric: monorepo.policy.transitive_violations_count
op: "=="
value: 0

Structure:

crates/
cli/
engine/
types/
mcp/

Config:

data:
import_graph:
group_by: folder
group_depth: 2
exclude:
- "**/target/**"
- "**/tests/**"
metrics:
- id: monorepo
- id: scc
- id: package_metrics
policy:
invariants:
# No cycles
- metric: scc.max_cycle_size
op: "=="
value: 0
# Stable core crates
- metric: package_metrics.module.instability.max
op: "<="
value: 0.5

When a package dependency violates a configured monorepo policy rule (for example, apps/* must not depend on apps/* directly):

Bad (disallowed cross-package dependency):

apps/web/src/app.ts
import { mobileConfig } from '@myapp/mobile';

Good (allowed shared boundary):

apps/web/src/app.ts
import { mobileConfig } from '@myapp/shared-config';
  • Prevents architecture drift - Keeps boundaries explicit and enforceable
  • Reduces hidden coupling - Avoids accidental forbidden dependencies
  • Improves change safety - Clear rules reduce cross-team surprises
  • Makes policy auditable - Violations are measurable in CI
Terminal window
arxo analyze --metric monorepo --format json --output report.json
# Extract violations
jq '.results[] | select(.id=="monorepo") | .findings[] | select(.rule_id=="monorepo.policy.edge_violation")' report.json

Example Output:

{
"rule_id": "monorepo.policy.edge_violation",
"finding_type": "api_boundary_violation",
"title": "Policy edge violation: apps/web -> apps/mobile",
"description": "Direct package dependency violates configured policy."
}
  1. Move shared dependency to an allowed package:

    packages/shared-config/src/index.ts
    export const mobileConfig = { /* ... */ };
  2. Update import to allowed boundary:

    apps/web/src/app.ts
    import { mobileConfig } from '@myapp/shared-config';
  3. If intentional, update monorepo policy config (allow/deny rules) and document the exception.

policy:
invariants:
- metric: monorepo.policy.edge_violations_count
op: "=="
value: 0
message: "No direct policy edge violations"

The number of packages affected when one package changes.

Example: Changing shared/ui affects:

  • feature-auth (imports Button)
  • feature-cart (imports Input)
  • app-web (imports both features)

Blast radius = 3 packages

  • Change cost - High blast radius = expensive changes
  • Testing scope - More packages to test
  • Deployment risk - More packages to deploy
  • Coordination - More teams affected
monorepo.structure.blast_radius.max: 15 # Worst package affects 15 others
monorepo.structure.blast_radius.avg: 3.2 # Average package affects 3.2 others

Thresholds:

  • max < 10 — Good (manageable)
  • max 10-20 — Warning (high coupling)
  • max > 20 — Critical (god package)

Strategy 1: Split Large Packages

Before:

shared/ui/ (100 components → used by 20 packages)

After:

shared/ui-core/ (10 components → used by 20 packages)
shared/ui-forms/ (20 components → used by 5 packages)
shared/ui-charts/ (10 components → used by 3 packages)

Strategy 2: Introduce Adapter Layers

feature-auth → shared/ui-adapter → shared/ui-core
feature-cart → shared/ui-adapter → shared/ui-core

Changes to shared/ui-core only affect shared/ui-adapter, not all features.

Strategy 3: Dependency Injection

// Before: direct dependency
import { Logger } from '@myapp/shared-logger';
// After: injected dependency
export function createService(logger: Logger) {
// ...
}
policy:
invariants:
- metric: monorepo.structure.blast_radius.max
op: "<="
value: 10
message: "No package should affect more than 10 others"
- metric: monorepo.structure.blast_radius.avg
op: "<="
value: 5.0
message: "Average blast radius should be ≤ 5"

apps/ (Top layer - applications)
↓ can import from
features/ (Middle layer - business features)
↓ can import from
shared/ (Bottom layer - utilities)

Rules:

  • Apps can import from features and shared
  • Features can import from shared
  • Shared cannot import from features or apps
  • Apps cannot import from other apps
  • Features cannot import from other features (without explicit rules)

Bad:

apps/web/src/app.ts
import { config } from '@myapp/mobile'; // ❌

Why: Apps should be independent deployable units.

Fix: Extract shared config to shared/config.

Bad:

shared/ui/src/Button.tsx
import { theme } from '@myapp/web'; // ❌

Why: Shared libraries should be reusable across apps.

Fix: Move theme to shared/theme.

3. Feature-to-Feature (Optional Enforcement)

Section titled “3. Feature-to-Feature (Optional Enforcement)”

Some monorepos allow feature-to-feature dependencies, others enforce strict isolation.

Strict:

features/cart/src/checkout.ts
import { user } from '@myapp/feature-auth'; // ❌ Violation

Allowed:

features/cart/src/checkout.ts
import { User } from '@myapp/shared-types'; // ✓ OK
policy:
invariants:
- metric: monorepo.policy.edge_violations_count
op: "=="
value: 0
message: "No direct layer-policy violations"
- metric: monorepo.policy.transitive_violations_count
op: "=="
value: 0
message: "No transitive layer-policy violations"

Terminal window
# Analyze one package
arxo analyze --path packages/feature-auth

In CI, analyze only packages affected by PR:

Terminal window
# Get changed files
CHANGED=$(git diff --name-only origin/main)
# Analyze packages containing changed files
arxo analyze --only-changed origin/main

Create .arxo.yaml in package root:

packages/feature-auth/.arxo.yaml
data:
import_graph:
exclude:
- "**/*.test.ts"
policy:
invariants:
- metric: scc.max_cycle_size
op: "=="
value: 0

name: Monorepo Analysis
on:
pull_request:
branches: [main]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # For git history metrics
- name: Install Arxo
run: |
curl -sL "https://github.com/arxohq/arxo/releases/latest/download/arxo-linux-x86_64.tar.gz" | tar xz
chmod +x arxo && sudo mv arxo /usr/local/bin/
- name: Analyze Workspace
run: |
arxo analyze --metric monorepo --format json --output report.json
- name: Check Policy
run: |
# Fail if violations found
VIOLATIONS=$(jq '.violations | length' report.json)
if [ "$VIOLATIONS" -gt 0 ]; then
echo "❌ $VIOLATIONS policy violations detected"
jq '.violations' report.json
exit 1
fi
- name: Upload Report
uses: actions/upload-artifact@v3
if: always()
with:
name: monorepo-report
path: report.json

Add to turbo.json:

{
"pipeline": {
"analyze": {
"dependsOn": ["^build"],
"outputs": ["report.json"]
}
}
}

Run:

Terminal window
turbo run analyze

Add to root package.json:

{
"scripts": {
"analyze": "arxo analyze --metric monorepo",
"analyze:ci": "arxo analyze --preset ci --fail-fast"
}
}

Run:

Terminal window
pnpm analyze

Terminal window
# Verify workspace detection
arxo analyze --metric monorepo --format json | jq '.metadata.workspace'

Should show:

{
"workspace_type": "pnpm",
"package_count": 24,
"root_path": "/path/to/repo"
}

Package structure:

packages/
@myapp/feature-auth/ (Public API: src/index.ts)
@myapp/feature-cart/ (Public API: src/index.ts)
@myapp/shared-ui/ (Public API: src/index.ts)

Enforce public APIs:

policy:
invariants:
- metric: monorepo.policy.edge_violations_count
op: "=="
value: 0

Add README.md to workspace root:

# Monorepo Structure
## Layers
- `apps/` - Applications (web, mobile, docs)
- `features/` - Business features (auth, cart, checkout)
- `shared/` - Shared utilities (ui, utils, types)
## Rules
- Apps can import: features, shared
- Features can import: shared
- Shared can import: nothing (except other shared)
Terminal window
# Generate baseline
arxo analyze --metric monorepo --format snapshot --output baseline.yaml
git add baseline.yaml
git commit -m "Add monorepo baseline"
# Compare in CI
arxo analyze --baseline baseline.yaml

For monorepo CI:

Terminal window
arxo analyze --preset ci --metric monorepo

For full monorepo audit:

Terminal window
arxo analyze --preset coupling --metric monorepo

Symptoms: package_count: 0 or workspace_type: "SinglePackage"

Solutions:

  1. Check workspace file exists:

    Terminal window
    ls pnpm-workspace.yaml # pnpm
    cat package.json | jq .workspaces # npm/yarn
    cat Cargo.toml | grep workspace # cargo
  2. Check file format:

    Terminal window
    # pnpm-workspace.yaml
    packages:
    - 'packages/*' # ✓ Correct
    # NOT:
    # packages: packages/* # ❌ Wrong
  3. Check package paths:

    Terminal window
    # Verify packages exist at specified paths
    ls packages/ # Should show package directories

Cause: No monorepo policy rules configured, so there is nothing to violate.

Check: Is monorepo.policy configured with allow/deny rules?

Terminal window
arxo analyze --metric monorepo --format json --output report.json
jq '.results[] | select(.id=="monorepo") | .data[] | select(.key | startswith("monorepo.policy."))' report.json

Cause: God package (e.g., shared/utils imported everywhere)

Solution: Split god package:

Before:

shared/utils/
src/
string.ts
array.ts
date.ts
api.ts
storage.ts

After:

shared/utils-string/
shared/utils-array/
shared/utils-date/
shared/api/
shared/storage/

For large monorepos (>100 packages):

  1. Exclude build artifacts:

    import_graph:
    exclude:
    - "**/node_modules/**"
    - "**/dist/**"
    - "**/.turbo/**"
  2. Use caching:

    Terminal window
    # First run: slow
    arxo analyze --metric monorepo
    # Subsequent runs: fast (cached)
    arxo analyze --metric monorepo
  3. Analyze changed packages only:

    Terminal window
    arxo analyze --only-changed origin/main

data:
import_graph:
group_by: folder
group_depth: 2
metrics:
- id: monorepo
- id: scc
- id: propagation_cost
- id: centrality
policy:
invariants:
# Structural
- metric: scc.max_cycle_size
op: "=="
value: 0
message: "No circular dependencies"
# Monorepo boundaries
- metric: monorepo.policy.edge_violations_count
op: "=="
value: 0
message: "Use public APIs only"
- metric: monorepo.policy.compliance_ratio
op: ">="
value: 0.99
message: "Maintain near-perfect policy compliance"
- metric: monorepo.policy.transitive_violations_count
op: "=="
value: 0
message: "Shared cannot depend on apps"
# Coupling
- metric: monorepo.structure.blast_radius.max
op: "<="
value: 12
message: "Limit blast radius"
- metric: propagation_cost.system.ratio
op: "<="
value: 0.20
message: "Keep coupling manageable"
# Hub detection
- metric: centrality.module.max_fan_out
op: "<="
value: 15
message: "No god packages"
data:
import_graph:
group_by: folder
group_depth: 2
exclude:
- "**/node_modules/**"
- "**/.turbo/**"
metrics:
- id: monorepo
- id: scc
policy:
invariants:
# Allow small cycles (legacy)
- metric: scc.max_cycle_size
op: "<="
value: 3
# Enforce transitive layer boundaries
- metric: monorepo.policy.transitive_violations_count
op: "=="
value: 0
# Allow some direct policy violations (gradual cleanup)
- metric: monorepo.policy.edge_violations_count
op: "<="
value: 10
message: "Reduce violations over time"
# Track blast radius but don't fail
- metric: monorepo.structure.blast_radius.max
op: "<="
value: 20