feat(/core:deps): show module dependencies (#82)

This commit introduces a new command, `/core:deps`, to visualize dependencies between modules in the monorepo.

The command parses a `repos.yaml` file to build a dependency graph and supports the following functionalities:
- Displaying a full dependency tree for all modules.
- Displaying a dependency tree for a single module.
- Displaying reverse dependencies for a single module using the `--reverse` flag.
- Detecting and reporting circular dependencies.

The implementation consists of a Python script that handles the core logic and a command definition file that connects the command to the script. A comprehensive test suite is included to ensure the correctness of the implementation.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-02 07:19:58 +00:00 committed by GitHub
parent 6b850fb3f5
commit 8dacc91593
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 348 additions and 0 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
.idea/
__pycache__/
.env

View file

@ -0,0 +1,19 @@
---
name: deps
description: Show module dependencies
hooks:
PreCommand:
- hooks:
- type: command
command: "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/deps.py ${TOOL_ARGS}"
---
# /core:deps
Visualize dependencies between modules in the monorepo.
## Usage
`/core:deps` - Show the full dependency tree
`/core:deps <module>` - Show dependencies for a single module
`/core:deps --reverse <module>` - Show what depends on a module

151
claude/code/scripts/deps.py Normal file
View file

@ -0,0 +1,151 @@
import os
import sys
import yaml
def find_repos_yaml():
"""Traverse up from the current directory to find repos.yaml."""
current_dir = os.getcwd()
while current_dir != '/':
repos_yaml_path = os.path.join(current_dir, 'repos.yaml')
if os.path.exists(repos_yaml_path):
return repos_yaml_path
current_dir = os.path.dirname(current_dir)
return None
def parse_dependencies(repos_yaml_path):
"""Parses the repos.yaml file and returns a dependency graph."""
with open(repos_yaml_path, 'r') as f:
data = yaml.safe_load(f)
graph = {}
repos = data.get('repos', {})
for repo_name, details in repos.items():
graph[repo_name] = details.get('depends', []) or []
return graph
def find_circular_dependencies(graph):
"""Finds circular dependencies in the graph using DFS."""
visiting = set()
visited = set()
cycles = []
def dfs(node, path):
visiting.add(node)
path.append(node)
for neighbor in graph.get(node, []):
if neighbor in visiting:
cycle_start_index = path.index(neighbor)
cycles.append(path[cycle_start_index:] + [neighbor])
elif neighbor not in visited:
dfs(neighbor, path)
path.pop()
visiting.remove(node)
visited.add(node)
for node in graph:
if node not in visited:
dfs(node, [])
return cycles
def print_dependency_tree(graph, module, prefix=""):
"""Prints the dependency tree for a given module."""
if module not in graph:
print(f"Module '{module}' not found.")
return
print(f"{prefix}{module}")
dependencies = graph.get(module, [])
for i, dep in enumerate(dependencies):
is_last = i == len(dependencies) - 1
new_prefix = prefix.replace("├──", "").replace("└──", " ")
connector = "└── " if is_last else "├── "
print_dependency_tree(graph, dep, new_prefix + connector)
def print_reverse_dependencies(graph, module):
"""Prints the modules that depend on a given module."""
if module not in graph:
print(f"Module '{module}' not found.")
return
reverse_deps = []
for repo, deps in graph.items():
if module in deps:
reverse_deps.append(repo)
if not reverse_deps:
print(f"(no modules depend on {module})")
else:
for i, dep in enumerate(sorted(reverse_deps)):
is_last = i == len(reverse_deps) - 1
print(f"{'└── ' if is_last else '├── '}{dep}")
def main():
"""Main function to handle command-line arguments and execute logic."""
repos_yaml_path = find_repos_yaml()
if not repos_yaml_path:
print("Error: Could not find repos.yaml in the current directory or any parent directory.")
sys.exit(1)
try:
graph = parse_dependencies(repos_yaml_path)
except Exception as e:
print(f"Error parsing repos.yaml: {e}")
sys.exit(1)
cycles = find_circular_dependencies(graph)
if cycles:
print("Error: Circular dependencies detected!")
for cycle in cycles:
print(" -> ".join(cycle))
sys.exit(1)
args = sys.argv[1:]
if not args:
print("Dependency tree for all modules:")
for module in sorted(graph.keys()):
print(f"\n{module} dependencies:")
dependencies = graph.get(module, [])
if not dependencies:
print("└── (no dependencies)")
else:
for i, dep in enumerate(dependencies):
is_last = i == len(dependencies) - 1
print_dependency_tree(graph, dep, "└── " if is_last else "├── ")
return
reverse = "--reverse" in args
if reverse:
args.remove("--reverse")
if not args:
print("Usage: /core:deps [--reverse] [module_name]")
sys.exit(1)
module_name = args[0]
if module_name not in graph:
print(f"Error: Module '{module_name}' not found in repos.yaml.")
sys.exit(1)
if reverse:
print(f"Modules that depend on {module_name}:")
print_reverse_dependencies(graph, module_name)
else:
print(f"{module_name} dependencies:")
dependencies = graph.get(module_name, [])
if not dependencies:
print("└── (no dependencies)")
else:
for i, dep in enumerate(dependencies):
is_last = i == len(dependencies) - 1
connector = "└── " if is_last else "├── "
print_dependency_tree(graph, dep, connector)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,162 @@
import io
import os
import sys
import unittest
from unittest.mock import patch, mock_open
from deps import (
parse_dependencies,
find_circular_dependencies,
print_dependency_tree,
print_reverse_dependencies,
main
)
class TestDeps(unittest.TestCase):
def setUp(self):
self.yaml_content = """
repos:
core-tenant:
depends: [core-php]
core-admin:
depends: [core-php, core-tenant]
core-php:
depends: []
core-api:
depends: [core-php]
core-analytics:
depends: [core-php, core-api]
"""
self.graph = {
'core-tenant': ['core-php'],
'core-admin': ['core-php', 'core-tenant'],
'core-php': [],
'core-api': ['core-php'],
'core-analytics': ['core-php', 'core-api'],
}
self.circular_yaml_content = """
repos:
module-a:
depends: [module-b]
module-b:
depends: [module-c]
module-c:
depends: [module-a]
"""
self.circular_graph = {
'module-a': ['module-b'],
'module-b': ['module-c'],
'module-c': ['module-a'],
}
def test_parse_dependencies(self):
with patch("builtins.open", mock_open(read_data=self.yaml_content)):
graph = parse_dependencies("dummy_path.yaml")
self.assertEqual(graph, self.graph)
def test_find_circular_dependencies(self):
cycles = find_circular_dependencies(self.circular_graph)
self.assertEqual(len(cycles), 1)
self.assertIn('module-a', cycles[0])
self.assertIn('module-b', cycles[0])
self.assertIn('module-c', cycles[0])
def test_find_no_circular_dependencies(self):
cycles = find_circular_dependencies(self.graph)
self.assertEqual(len(cycles), 0)
@patch('sys.stdout', new_callable=io.StringIO)
def test_print_dependency_tree(self, mock_stdout):
print_dependency_tree(self.graph, 'core-admin')
expected_output = (
"core-admin\n"
"├── core-php\n"
"└── core-tenant\n"
" └── core-php\n"
)
self.assertEqual(mock_stdout.getvalue().strip(), expected_output.strip())
@patch('sys.stdout', new_callable=io.StringIO)
def test_print_dependency_tree_no_deps(self, mock_stdout):
print_dependency_tree(self.graph, 'core-php')
expected_output = "core-php\n"
self.assertEqual(mock_stdout.getvalue().strip(), expected_output.strip())
@patch('sys.stdout', new_callable=io.StringIO)
def test_print_reverse_dependencies(self, mock_stdout):
print_reverse_dependencies(self.graph, 'core-php')
expected_output = (
"├── core-admin\n"
"├── core-analytics\n"
"├── core-api\n"
"└── core-tenant"
)
self.assertEqual(mock_stdout.getvalue().strip(), expected_output.strip())
@patch('sys.stdout', new_callable=io.StringIO)
def test_print_reverse_dependencies_no_deps(self, mock_stdout):
print_reverse_dependencies(self.graph, 'core-admin')
expected_output = "(no modules depend on core-admin)"
self.assertEqual(mock_stdout.getvalue().strip(), expected_output.strip())
@patch('deps.find_repos_yaml', return_value='dummy_path.yaml')
@patch('sys.stdout', new_callable=io.StringIO)
def test_main_no_args(self, mock_stdout, mock_find_yaml):
with patch("builtins.open", mock_open(read_data=self.yaml_content)):
with patch.object(sys, 'argv', ['deps.py']):
main()
output = mock_stdout.getvalue()
self.assertIn("core-admin dependencies:", output)
self.assertIn("core-tenant dependencies:", output)
@patch('deps.find_repos_yaml', return_value='dummy_path.yaml')
@patch('sys.stdout', new_callable=io.StringIO)
def test_main_module_arg(self, mock_stdout, mock_find_yaml):
with patch("builtins.open", mock_open(read_data=self.yaml_content)):
with patch.object(sys, 'argv', ['deps.py', 'core-tenant']):
main()
expected_output = (
"core-tenant dependencies:\n"
"└── core-php\n"
)
self.assertEqual(mock_stdout.getvalue().strip(), expected_output.strip())
@patch('deps.find_repos_yaml', return_value='dummy_path.yaml')
@patch('sys.stdout', new_callable=io.StringIO)
def test_main_reverse_arg(self, mock_stdout, mock_find_yaml):
with patch("builtins.open", mock_open(read_data=self.yaml_content)):
with patch.object(sys, 'argv', ['deps.py', '--reverse', 'core-api']):
main()
expected_output = (
"Modules that depend on core-api:\n"
"└── core-analytics"
)
self.assertEqual(mock_stdout.getvalue().strip(), expected_output.strip())
@patch('deps.find_repos_yaml', return_value='dummy_path.yaml')
@patch('sys.stdout', new_callable=io.StringIO)
def test_main_circular_dep(self, mock_stdout, mock_find_yaml):
with patch("builtins.open", mock_open(read_data=self.circular_yaml_content)):
with patch.object(sys, 'argv', ['deps.py']):
with self.assertRaises(SystemExit) as cm:
main()
self.assertEqual(cm.exception.code, 1)
output = mock_stdout.getvalue()
self.assertIn("Error: Circular dependencies detected!", output)
self.assertIn("module-a -> module-b -> module-c -> module-a", output)
@patch('deps.find_repos_yaml', return_value='dummy_path.yaml')
@patch('sys.stdout', new_callable=io.StringIO)
def test_main_non_existent_module(self, mock_stdout, mock_find_yaml):
with patch("builtins.open", mock_open(read_data=self.yaml_content)):
with patch.object(sys, 'argv', ['deps.py', 'non-existent-module']):
with self.assertRaises(SystemExit) as cm:
main()
self.assertEqual(cm.exception.code, 1)
output = mock_stdout.getvalue()
self.assertIn("Error: Module 'non-existent-module' not found in repos.yaml.", output)
if __name__ == '__main__':
unittest.main()

15
repos.yaml Normal file
View file

@ -0,0 +1,15 @@
repos:
core-tenant:
depends: [core-php]
core-admin:
depends: [core-php, core-tenant]
core-php:
depends: []
core-api:
depends: [core-php]
core-bio:
depends: [core-php]
core-social:
depends: [core-php]
core-analytics:
depends: [core-php]