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:
parent
6b850fb3f5
commit
8dacc91593
5 changed files with 348 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
.idea/
|
||||
__pycache__/
|
||||
.env
|
||||
|
|
|
|||
19
claude/code/commands/deps.md
Normal file
19
claude/code/commands/deps.md
Normal 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
151
claude/code/scripts/deps.py
Normal 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()
|
||||
162
claude/code/scripts/test_deps.py
Normal file
162
claude/code/scripts/test_deps.py
Normal 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
15
repos.yaml
Normal 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]
|
||||
Loading…
Add table
Reference in a new issue