Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,6 @@ jobs:
- name: Check CLI dependencies
if: needs.detect-changes.outputs.cli == 'true' || needs.detect-changes.outputs.core == 'true'
run: uv run deptry packages/busylight/src --config packages/busylight/pyproject.toml

- name: Verify cross-package dependencies (Dispatch #68)
run: uv run python scripts/verify-cross-package-deps.py
74 changes: 74 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Scripts

## Cross-Package Dependency Verification

### `verify-cross-package-deps.py` (Dispatch #68)

This script implements cross-package dependency verification to prevent the monorepo from silently devolving into a monolith by catching undeclared cross-package dependencies.

#### Purpose

In a monorepo, it's easy for packages to accidentally import from other packages without declaring them as proper dependencies. This creates hidden coupling and defeats the purpose of having separate packages. The verification script ensures that:

1. All cross-package imports are explicitly declared in `pyproject.toml` dependencies
2. No undeclared cross-package dependencies exist
3. The monorepo maintains proper dependency boundaries

#### How it works

1. **Discovery**: Scans all workspace members defined in the root `pyproject.toml`
2. **Import Analysis**: Uses Python's AST to extract all imports from each package's source files
3. **Cross-Reference**: Matches imports against declared dependencies in each package's `pyproject.toml`
4. **Validation**: Reports any cross-package imports that lack corresponding dependency declarations

#### Usage

```bash
# Run from monorepo root
uv run python scripts/verify-cross-package-deps.py
```

#### CI Integration

The script is integrated into the CI workflow as part of the `dependency-check` job. It runs on every PR and push to catch dependency boundary violations early.

#### Example Output

**Success:**
```
Found 2 workspace packages:
- busylight-for-humans (module: busylight)
- busylight_core (module: busylight_core)

Checking busylight-for-humans...
Checking busylight_core...
✅ All cross-package dependencies are properly declared!
Monorepo dependency boundaries are maintained.
```

**Failure:**
```
❌ Found undeclared cross-package dependencies:
These imports violate monorepo dependency boundaries:

• busylight_core: imports 'busylight' from package 'busylight-for-humans'
but 'busylight-for-humans' is not declared in dependencies

💡 To fix these issues:
1. Add the missing dependencies to the appropriate pyproject.toml
2. Or refactor the code to avoid the cross-package import
3. This prevents the monorepo from becoming a monolith

Total issues: 1
```

#### Technical Details

- **AST Parsing**: Uses Python's `ast` module to accurately parse import statements
- **Relative Import Handling**: Correctly ignores relative imports (`.module`) within packages
- **Dependency Mapping**: Handles both package names (`busylight-core`) and module names (`busylight_core`)
- **Workspace Detection**: Automatically discovers packages via uv workspace configuration

#### Relationship to existing tools

This complements the existing `deptry` checks by focusing specifically on cross-package boundaries within the monorepo, while `deptry` focuses on external dependencies.
209 changes: 209 additions & 0 deletions scripts/verify-cross-package-deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
Cross-package dependency verification for busylight monorepo.

This tool scans all imports in each workspace member and cross-references
against declared dependencies in pyproject.toml to prevent the monorepo
from silently devolving into a monolith by catching undeclared cross-package
dependencies.

Dispatch #68: Add prek dependency verification to busylight-core monorepo CI
"""

import ast
import sys
from pathlib import Path
from typing import Dict, List, Set

import tomllib


class ImportVisitor(ast.NodeVisitor):
"""Extract all imports from Python AST."""

def __init__(self):
self.imports: Set[str] = set()

def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
module_name = alias.name.split('.')[0]
# Only add valid Python identifiers (avoid things like "busylight-core")
if module_name.isidentifier():
self.imports.add(module_name)

def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if node.module:
# Skip relative imports (node.level > 0 indicates relative import)
if node.level == 0:
module_name = node.module.split('.')[0]
# Only add valid Python identifiers
if module_name.isidentifier():
self.imports.add(module_name)


def get_workspace_packages(root_path: Path) -> Dict[str, Dict]:
"""Get all workspace packages and their metadata."""
root_pyproject = root_path / "pyproject.toml"
if not root_pyproject.exists():
raise FileNotFoundError(f"Root pyproject.toml not found at {root_pyproject}")

with open(root_pyproject, 'rb') as f:
root_config = tomllib.load(f)

workspace_members = root_config.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", [])

packages = {}
for member_pattern in workspace_members:
member_dirs = root_path.glob(member_pattern)
for member_dir in member_dirs:
if member_dir.is_dir():
pyproject_path = member_dir / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, 'rb') as f:
config = tomllib.load(f)

project_name = config.get("project", {}).get("name", "")
if project_name:
# Determine the actual module name by checking src structure
module_name = project_name.replace("-", "_")

# Check if there's a src directory structure
src_path = member_dir / "src"
if src_path.exists():
# Find the actual module directory under src
for item in src_path.iterdir():
if item.is_dir() and not item.name.startswith('.'):
module_name = item.name
break

packages[project_name] = {
"path": member_dir,
"config": config,
"module_name": module_name
}

return packages


def extract_imports_from_file(file_path: Path) -> Set[str]:
"""Extract all top-level imports from a Python file."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()

tree = ast.parse(content)
visitor = ImportVisitor()
visitor.visit(tree)
return visitor.imports
except (SyntaxError, UnicodeDecodeError) as e:
print(f"Warning: Could not parse {file_path}: {e}")
return set()


def get_package_imports(package_path: Path) -> Set[str]:
"""Get all imports from all Python files in a package."""
imports = set()

# Scan src directory if it exists, otherwise scan the package directory
src_path = package_path / "src"
scan_path = src_path if src_path.exists() else package_path

for py_file in scan_path.rglob("*.py"):
# Skip __pycache__ and test files
if "__pycache__" in str(py_file) or "tests/" in str(py_file):
continue

file_imports = extract_imports_from_file(py_file)
imports.update(file_imports)

return imports


def get_declared_dependencies(config: Dict) -> Set[str]:
"""Extract declared dependencies from pyproject.toml config."""
dependencies = set()

# Get main dependencies
deps = config.get("project", {}).get("dependencies", [])
for dep in deps:
# Extract package name from dependency spec (e.g. "busylight-core>=2.3.0" -> "busylight-core")
dep_name = dep.split(">=")[0].split("==")[0].split("~=")[0].split("<")[0].split(">")[0].strip()
dependencies.add(dep_name)

return dependencies


def check_cross_package_dependencies(packages: Dict[str, Dict]) -> List[str]:
"""Check for undeclared cross-package dependencies."""
issues = []

# Create a mapping of module names to package names
module_to_package = {info["module_name"]: name for name, info in packages.items()}

for package_name, package_info in packages.items():
print(f"Checking {package_name}...")

# Get all imports used by this package
package_imports = get_package_imports(package_info["path"])

# Get declared dependencies
declared_deps = get_declared_dependencies(package_info["config"])

# Check for cross-package imports
for imported_module in package_imports:
if imported_module in module_to_package:
# This is a cross-package import
target_package = module_to_package[imported_module]

# Skip self-imports
if target_package == package_name:
continue

# Check if this cross-package dependency is declared
# Handle both the declared package name and normalized module name
target_package_normalized = target_package.replace("_", "-")
if target_package not in declared_deps and target_package_normalized not in declared_deps:
issues.append(
f"{package_name}: imports '{imported_module}' from package '{target_package}' "
f"but '{target_package}' (or '{target_package_normalized}') is not declared in dependencies"
)

return issues


def main():
"""Main entry point."""
root_path = Path.cwd()

try:
packages = get_workspace_packages(root_path)
print(f"Found {len(packages)} workspace packages:")
for name, info in packages.items():
print(f" - {name} (module: {info['module_name']})")
print()

issues = check_cross_package_dependencies(packages)

if issues:
print("❌ Found undeclared cross-package dependencies:")
print(" These imports violate monorepo dependency boundaries:")
print()
for issue in issues:
print(f" • {issue}")
print("\n💡 To fix these issues:")
print(" 1. Add the missing dependencies to the appropriate pyproject.toml")
print(" 2. Or refactor the code to avoid the cross-package import")
print(" 3. This prevents the monorepo from becoming a monolith")
print(f"\nTotal issues: {len(issues)}")
sys.exit(1)
else:
print("✅ All cross-package dependencies are properly declared!")
print(" Monorepo dependency boundaries are maintained.")

except Exception as e:
print(f"Error: {e}")
sys.exit(1)


if __name__ == "__main__":
main()