From 8dacc91593fc115fd43086c6f8f3abbbe5ab0977 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 07:19:58 +0000 Subject: [PATCH] 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 --- .gitignore | 1 + claude/code/commands/deps.md | 19 ++++ claude/code/scripts/deps.py | 151 ++++++++++++++++++++++++++++ claude/code/scripts/test_deps.py | 162 +++++++++++++++++++++++++++++++ repos.yaml | 15 +++ 5 files changed, 348 insertions(+) create mode 100644 claude/code/commands/deps.md create mode 100644 claude/code/scripts/deps.py create mode 100644 claude/code/scripts/test_deps.py create mode 100644 repos.yaml diff --git a/.gitignore b/.gitignore index 3ad1afd..9f4eae7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ +__pycache__/ .env diff --git a/claude/code/commands/deps.md b/claude/code/commands/deps.md new file mode 100644 index 0000000..56bc678 --- /dev/null +++ b/claude/code/commands/deps.md @@ -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 ` - Show dependencies for a single module +`/core:deps --reverse ` - Show what depends on a module diff --git a/claude/code/scripts/deps.py b/claude/code/scripts/deps.py new file mode 100644 index 0000000..42fbe8d --- /dev/null +++ b/claude/code/scripts/deps.py @@ -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() diff --git a/claude/code/scripts/test_deps.py b/claude/code/scripts/test_deps.py new file mode 100644 index 0000000..744e711 --- /dev/null +++ b/claude/code/scripts/test_deps.py @@ -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() diff --git a/repos.yaml b/repos.yaml new file mode 100644 index 0000000..0ca21b1 --- /dev/null +++ b/repos.yaml @@ -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]