diff --git a/docs/src/content/docs/api/dependency-analyzer.mdx b/docs/src/content/docs/api/dependency-analyzer.mdx
index f7554079..619cacf1 100644
--- a/docs/src/content/docs/api/dependency-analyzer.mdx
+++ b/docs/src/content/docs/api/dependency-analyzer.mdx
@@ -354,6 +354,125 @@ docs = terraform_analyzer.generate_resource_documentation(
* String containing the markdown documentation of resources
+### GoDependencyAnalyzer
+
+**Class: `GoDependencyAnalyzer`**
+*(defined in `kit/dependency_analyzer/go_dependency_analyzer.py`)*
+
+The `GoDependencyAnalyzer` extends the base `DependencyAnalyzer` to analyze Go codebases, focusing on package import relationships.
+
+#### Additional Methods
+
+##### `get_package_dependencies`
+
+**Method: `GoDependencyAnalyzer.get_package_dependencies`**
+*(defined in `kit/dependency_analyzer/go_dependency_analyzer.py`)*
+
+Gets dependencies for a specific Go package.
+
+```python
+# Get direct dependencies
+deps = go_analyzer.get_package_dependencies("github.com/myorg/myapp/pkg/server")
+
+# Get all dependencies (including indirect)
+all_deps = go_analyzer.get_package_dependencies(
+ "github.com/myorg/myapp/pkg/server",
+ include_indirect=True
+)
+```
+
+**Parameters:**
+
+* **`package_path`** (`str`, required):
+ Full import path of the package to check.
+* **`include_indirect`** (`bool`, optional):
+ Whether to include indirect dependencies. Defaults to `False`.
+
+**Returns:**
+
+* List of package import paths this package depends on
+
+### JavaScriptDependencyAnalyzer
+
+**Class: `JavaScriptDependencyAnalyzer`**
+*(defined in `kit/dependency_analyzer/javascript_dependency_analyzer.py`)*
+
+The `JavaScriptDependencyAnalyzer` extends the base `DependencyAnalyzer` to analyze JavaScript and TypeScript codebases, supporting both ESM (`import`) and CommonJS (`require`) module systems.
+
+#### Additional Methods
+
+##### `get_module_dependencies`
+
+**Method: `JavaScriptDependencyAnalyzer.get_module_dependencies`**
+*(defined in `kit/dependency_analyzer/javascript_dependency_analyzer.py`)*
+
+Gets dependencies for a specific JavaScript/TypeScript module.
+
+```python
+# Get direct dependencies
+deps = js_analyzer.get_module_dependencies("src/index.js")
+
+# Get all dependencies (including indirect)
+all_deps = js_analyzer.get_module_dependencies(
+ "src/index.js",
+ include_indirect=True
+)
+```
+
+**Parameters:**
+
+* **`module_path`** (`str`, required):
+ Path to the module file.
+* **`include_indirect`** (`bool`, optional):
+ Whether to include indirect dependencies. Defaults to `False`.
+
+**Returns:**
+
+* List of module paths/package names this module depends on
+
+##### `generate_dependency_report`
+
+**Method: `JavaScriptDependencyAnalyzer.generate_dependency_report`**
+*(defined in `kit/dependency_analyzer/javascript_dependency_analyzer.py`)*
+
+Generates a comprehensive dependency report for the repository.
+
+```python
+report = js_analyzer.generate_dependency_report(
+ output_path="dependency_report.json"
+)
+```
+
+**Parameters:**
+
+* **`output_path`** (`str`, optional):
+ Path to save the report JSON. If `None`, returns the report data without saving.
+
+**Returns:**
+
+* Dictionary with the complete dependency report including:
+ * Package name (from package.json)
+ * Internal module count
+ * External package count
+ * Node.js built-in usage
+ * Detected cycles
+ * High-dependency modules
+
+#### Supported Import Styles
+
+The JavaScript analyzer detects:
+- ESM imports: `import x from 'pkg'`, `import { x } from 'pkg'`
+- ESM re-exports: `export { x } from 'pkg'`, `export * from 'pkg'`
+- CommonJS: `require('pkg')`
+- Dynamic imports: `import('pkg')`
+
+#### Dependency Classification
+
+Dependencies are classified as:
+- **internal**: Relative imports (`./`, `../`)
+- **external**: npm packages (including scoped `@org/pkg`)
+- **node_builtin**: Node.js built-in modules (`fs`, `path`, `http`, etc.)
+
## Key Features and Notes
- All dependency analyzers store absolute file paths for resources, making it easy to locate components in complex codebases with files that might have the same name in different directories.
@@ -363,3 +482,5 @@ docs = terraform_analyzer.generate_resource_documentation(
- Visualizations require the Graphviz software to be installed on your system.
- The dependency graph is built on first use and cached. If the codebase changes, you may need to call `build_dependency_graph()` again to refresh the analysis.
+
+- **Supported languages**: Python, Terraform, Go, JavaScript/TypeScript
diff --git a/docs/src/content/docs/development/roadmap.mdx b/docs/src/content/docs/development/roadmap.mdx
index 60373092..955a0324 100644
--- a/docs/src/content/docs/development/roadmap.mdx
+++ b/docs/src/content/docs/development/roadmap.mdx
@@ -20,7 +20,7 @@ This roadmap reflects our current priorities. Updated quarterly.
## Now / Next
**Dependency Analysis Expansion**
-- JavaScript/TypeScript (package.json, ESM/CJS imports)
+- ~~JavaScript/TypeScript (package.json, ESM/CJS imports)~~ ✓ Shipped
- Rust (Cargo.toml, mod system)
- Java (Maven pom.xml, Gradle)
@@ -42,4 +42,4 @@ These are ideas we're considering. No commitments.
## Feedback
-Have ideas or priorities? [Open an issue](https://github.com/cased/kit/issues) or [start a discussion](https://github.com/cased/kit/discussions).
+Have ideas or priorities? [Open an issue](https://github.com/cased/kit/issues).
diff --git a/pyproject.toml b/pyproject.toml
index b5c4a8b8..f649e585 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cased-kit"
-version = "3.0.0"
+version = "3.1.0"
description = "A modular toolkit for LLM-powered codebase understanding."
authors = [
{ name = "Cased", email = "ted@cased.com" }
diff --git a/src/kit/cli.py b/src/kit/cli.py
index 6182e1b9..016fe2fd 100644
--- a/src/kit/cli.py
+++ b/src/kit/cli.py
@@ -338,7 +338,7 @@ def analyze_dependencies(
):
"""Analyze and visualize code dependencies within a repository.
- Supports Python, Terraform, and Go dependency analysis with features including:
+ Supports Python, Terraform, Go, and JavaScript/TypeScript dependency analysis with features including:
• Dependency graph generation and export
• Circular dependency detection
• Module-specific analysis
@@ -348,7 +348,7 @@ def analyze_dependencies(
Examples:
kit dependencies . --language python --format dot --output deps.dot
kit dependencies . --language terraform --cycles --visualize
- kit dependencies . --language python --module kit.repository --include-indirect
+ kit dependencies . --language javascript --module src/index.js --include-indirect
kit dependencies . --language python --llm-context --output context.md
"""
from kit import Repository
@@ -357,10 +357,10 @@ def analyze_dependencies(
repo = Repository(path, ref=ref)
# Validate language
- supported_languages = ["python", "terraform", "go", "golang"]
+ supported_languages = ["python", "terraform", "go", "golang", "javascript", "typescript", "js", "ts"]
if language.lower() not in supported_languages:
typer.secho(
- f"❌ Unsupported language: {language}. Supported: {', '.join(supported_languages)}",
+ f"❌ Unsupported language: {language}. Supported: python, terraform, go, javascript, typescript",
fg=typer.colors.RED,
)
raise typer.Exit(code=1)
@@ -407,23 +407,9 @@ def analyze_dependencies(
typer.secho(f"❌ Module/resource '{module}' not found in dependency graph", fg=typer.colors.RED)
raise typer.Exit(code=1)
- # Get dependencies and dependents using the correct methods for each analyzer type
- if language.lower() == "python":
- # Use the Python-specific methods
- if hasattr(analyzer, "get_module_dependencies") and hasattr(analyzer, "get_dependents"):
- dependencies = analyzer.get_module_dependencies(module, include_indirect=include_indirect) # type: ignore
- dependents = analyzer.get_dependents(module, include_indirect=include_indirect) # type: ignore
- else:
- typer.secho("❌ Python dependency analyzer methods not available", fg=typer.colors.RED)
- raise typer.Exit(code=1)
- else: # terraform
- # Use the Terraform-specific methods
- if hasattr(analyzer, "get_resource_dependencies") and hasattr(analyzer, "get_dependents"):
- dependencies = analyzer.get_resource_dependencies(module, include_indirect=include_indirect) # type: ignore
- dependents = analyzer.get_dependents(module, include_indirect=include_indirect) # type: ignore
- else:
- typer.secho("❌ Terraform dependency analyzer methods not available", fg=typer.colors.RED)
- raise typer.Exit(code=1)
+ # Get dependencies and dependents using the generic interface
+ dependencies = analyzer.get_dependencies(module, include_indirect=include_indirect) # type: ignore
+ dependents = analyzer.get_dependents(module, include_indirect=include_indirect) # type: ignore
dep_type = "All" if include_indirect else "Direct"
typer.echo(f"📥 {dep_type} dependencies ({len(dependencies)}):")
diff --git a/src/kit/dependency_analyzer/dependency_analyzer.py b/src/kit/dependency_analyzer/dependency_analyzer.py
index 50169c99..e59cfe3d 100644
--- a/src/kit/dependency_analyzer/dependency_analyzer.py
+++ b/src/kit/dependency_analyzer/dependency_analyzer.py
@@ -68,6 +68,34 @@ def find_cycles(self) -> List[List[str]]:
"""
pass
+ @abstractmethod
+ def get_dependencies(self, item: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get dependencies for a specific component.
+
+ Args:
+ item: Identifier for the component (file path, module name, resource ID, etc.)
+ include_indirect: Whether to include indirect/transitive dependencies
+
+ Returns:
+ List of component identifiers this item depends on
+ """
+ pass
+
+ @abstractmethod
+ def get_dependents(self, item: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get components that depend on the specified item.
+
+ Args:
+ item: Identifier for the component (file path, module name, resource ID, etc.)
+ include_indirect: Whether to include indirect/transitive dependents
+
+ Returns:
+ List of component identifiers that depend on this item
+ """
+ pass
+
@abstractmethod
def visualize_dependencies(self, output_path: str, format: str = "png") -> str:
"""
@@ -208,7 +236,7 @@ def get_for_language(cls, repository: "Repository", language: str) -> "Dependenc
Args:
repository: A kit.Repository instance
- language: Language identifier (e.g., 'python', 'terraform', 'go')
+ language: Language identifier (e.g., 'python', 'terraform', 'go', 'javascript', 'typescript')
Returns:
An appropriate DependencyAnalyzer instance for the language
@@ -217,6 +245,7 @@ def get_for_language(cls, repository: "Repository", language: str) -> "Dependenc
ValueError: If the specified language is not supported
"""
from .go_dependency_analyzer import GoDependencyAnalyzer
+ from .javascript_dependency_analyzer import JavaScriptDependencyAnalyzer
from .python_dependency_analyzer import PythonDependencyAnalyzer
from .terraform_dependency_analyzer import TerraformDependencyAnalyzer
@@ -228,10 +257,12 @@ def get_for_language(cls, repository: "Repository", language: str) -> "Dependenc
return TerraformDependencyAnalyzer(repository)
elif language == "go" or language == "golang":
return GoDependencyAnalyzer(repository)
+ elif language in ("javascript", "typescript", "js", "ts"):
+ return JavaScriptDependencyAnalyzer(repository)
else:
raise ValueError(
f"Unsupported language for dependency analysis: {language}. "
- f"Currently supported languages: python, terraform, go"
+ f"Currently supported languages: python, terraform, go, javascript, typescript"
)
# ------------------------------------------------------------------
diff --git a/src/kit/dependency_analyzer/go_dependency_analyzer.py b/src/kit/dependency_analyzer/go_dependency_analyzer.py
index d3567e9e..f9747bf5 100644
--- a/src/kit/dependency_analyzer/go_dependency_analyzer.py
+++ b/src/kit/dependency_analyzer/go_dependency_analyzer.py
@@ -502,6 +502,19 @@ def dfs(pkg):
return cycles
+ def get_dependencies(self, item: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get dependencies for a specific component.
+
+ Args:
+ item: Import path of the package
+ include_indirect: Whether to include indirect dependencies
+
+ Returns:
+ List of package import paths this package depends on
+ """
+ return self.get_package_dependencies(item, include_indirect)
+
def get_package_dependencies(self, package_path: str, include_indirect: bool = False) -> List[str]:
"""
Get dependencies for a specific package.
diff --git a/src/kit/dependency_analyzer/javascript_dependency_analyzer.py b/src/kit/dependency_analyzer/javascript_dependency_analyzer.py
new file mode 100644
index 00000000..c1c30512
--- /dev/null
+++ b/src/kit/dependency_analyzer/javascript_dependency_analyzer.py
@@ -0,0 +1,840 @@
+"""Analyzes and visualizes code dependencies within a JavaScript/TypeScript repository."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Union
+
+from .dependency_analyzer import DependencyAnalyzer
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from ..repository import Repository
+
+# Node.js built-in modules
+NODE_BUILTIN_MODULES = {
+ "assert",
+ "async_hooks",
+ "buffer",
+ "child_process",
+ "cluster",
+ "console",
+ "constants",
+ "crypto",
+ "dgram",
+ "diagnostics_channel",
+ "dns",
+ "domain",
+ "events",
+ "fs",
+ "http",
+ "http2",
+ "https",
+ "inspector",
+ "module",
+ "net",
+ "os",
+ "path",
+ "perf_hooks",
+ "process",
+ "punycode",
+ "querystring",
+ "readline",
+ "repl",
+ "stream",
+ "string_decoder",
+ "timers",
+ "tls",
+ "trace_events",
+ "tty",
+ "url",
+ "util",
+ "v8",
+ "vm",
+ "wasi",
+ "worker_threads",
+ "zlib",
+}
+
+
+class JavaScriptDependencyAnalyzer(DependencyAnalyzer):
+ """
+ Analyzes internal and external dependencies in a JavaScript/TypeScript codebase.
+
+ This class provides functionality to:
+ 1. Build a dependency graph of modules within a repository
+ 2. Identify import relationships between files (ESM and CommonJS)
+ 3. Export dependency information in various formats
+ 4. Detect dependency cycles and other potential issues
+ """
+
+ # Color scheme for visualization
+ _COLOR_MAP: ClassVar[Dict[str, str]] = {
+ "internal": "lightblue",
+ "node_builtin": "lightgray",
+ "external": "lightgreen",
+ }
+
+ def __init__(self, repository: "Repository"):
+ """
+ Initialize the analyzer with a Repository instance.
+
+ Args:
+ repository: A kit.Repository instance
+ """
+ super().__init__(repository)
+ self._package_name: Optional[str] = None
+ self._package_json: Optional[Dict[str, Any]] = None
+ self._file_map: Dict[str, str] = {} # normalized path -> actual path
+
+ def _parse_package_json(self) -> Optional[Dict[str, Any]]:
+ """Parse package.json to get package info and dependencies."""
+ try:
+ content = self.repo.get_file_content("package.json")
+ return json.loads(content)
+ except Exception as e:
+ logger.debug(f"Could not read package.json: {e}")
+ return None
+
+ def _extract_imports_from_file(self, file_path: str) -> List[Dict[str, Any]]:
+ """
+ Extract import statements from a JS/TS file using TreeSitter.
+
+ Args:
+ file_path: Path to the file
+
+ Returns:
+ List of dicts with 'source' (import path) and 'type' (esm/cjs/dynamic)
+ """
+ imports = []
+ try:
+ from tree_sitter_language_pack import get_parser
+
+ content = self.repo.get_file_content(file_path)
+
+ # Determine language
+ if file_path.endswith((".ts", ".tsx")):
+ parser = get_parser("typescript")
+ else:
+ parser = get_parser("javascript")
+
+ tree = parser.parse(content.encode("utf-8"))
+ imports = self._extract_imports_from_tree(tree.root_node, content)
+
+ except Exception as e:
+ logger.warning(f"Error extracting imports from {file_path} with tree-sitter: {e}")
+ # Fallback to regex
+ imports = self._extract_imports_regex(file_path)
+
+ return imports
+
+ def _extract_imports_from_tree(self, node, content: str) -> List[Dict[str, Any]]:
+ """Extract imports by walking the tree-sitter AST."""
+ imports = []
+
+ def get_text(n) -> str:
+ return content[n.start_byte : n.end_byte]
+
+ def visit(n):
+ # ESM: import x from 'source'
+ if n.type == "import_statement":
+ for child in n.children:
+ if child.type == "string":
+ source = get_text(child).strip("'\"")
+ imports.append({"source": source, "type": "esm"})
+
+ # ESM: export { x } from 'source'
+ elif n.type == "export_statement":
+ for child in n.children:
+ if child.type == "string":
+ source = get_text(child).strip("'\"")
+ imports.append({"source": source, "type": "esm"})
+
+ # CJS: require('source')
+ elif n.type == "call_expression":
+ func = n.child_by_field_name("function")
+ args = n.child_by_field_name("arguments")
+ if func and get_text(func) == "require" and args:
+ for arg in args.children:
+ if arg.type == "string":
+ source = get_text(arg).strip("'\"")
+ imports.append({"source": source, "type": "cjs"})
+
+ # Also check for dynamic import: import('source')
+ if func and func.type == "import":
+ for arg in args.children:
+ if arg.type == "string":
+ source = get_text(arg).strip("'\"")
+ imports.append({"source": source, "type": "dynamic"})
+
+ for child in n.children:
+ visit(child)
+
+ visit(node)
+ return imports
+
+ def _extract_imports_regex(self, file_path: str) -> List[Dict[str, Any]]:
+ """Fallback regex-based import extraction."""
+ imports = []
+ try:
+ content = self.repo.get_file_content(file_path)
+
+ # ESM imports: import ... from 'source' or import 'source'
+ esm_pattern = r"""(?:import\s+(?:(?:\*\s+as\s+\w+|\{[^}]*\}|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+))?\s+from\s+)?['"]([^'"]+)['"])"""
+ for match in re.finditer(esm_pattern, content):
+ imports.append({"source": match.group(1), "type": "esm"})
+
+ # ESM exports with source: export ... from 'source'
+ export_pattern = r"""export\s+(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]"""
+ for match in re.finditer(export_pattern, content):
+ imports.append({"source": match.group(1), "type": "esm"})
+
+ # CJS require: require('source')
+ cjs_pattern = r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)"""
+ for match in re.finditer(cjs_pattern, content):
+ imports.append({"source": match.group(1), "type": "cjs"})
+
+ # Dynamic import: import('source')
+ dynamic_pattern = r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)"""
+ for match in re.finditer(dynamic_pattern, content):
+ imports.append({"source": match.group(1), "type": "dynamic"})
+
+ except Exception as e:
+ logger.warning(f"Error in regex import extraction from {file_path}: {e}")
+
+ return imports
+
+ def _resolve_import(self, import_source: str, from_file: str) -> tuple[str, str]:
+ """
+ Resolve an import source to a module identifier and classify it.
+
+ Args:
+ import_source: The import path (e.g., './utils', 'lodash', 'fs')
+ from_file: The file containing the import
+
+ Returns:
+ Tuple of (resolved_module_id, type) where type is 'internal', 'node_builtin', or 'external'
+ """
+ # Handle node: protocol
+ if import_source.startswith("node:"):
+ module_name = import_source[5:]
+ return module_name, "node_builtin"
+
+ # Check for Node.js built-in
+ base_module = import_source.split("/")[0]
+ if base_module in NODE_BUILTIN_MODULES:
+ return base_module, "node_builtin"
+
+ # Relative imports are internal
+ if import_source.startswith("./") or import_source.startswith("../"):
+ # Resolve to absolute-ish path
+ from_dir = os.path.dirname(from_file)
+ resolved = os.path.normpath(os.path.join(from_dir, import_source))
+ # Normalize path separators
+ resolved = resolved.replace("\\", "/")
+ return resolved, "internal"
+
+ # Check for scoped packages (@org/pkg)
+ if import_source.startswith("@"):
+ parts = import_source.split("/")
+ if len(parts) >= 2:
+ package_name = f"{parts[0]}/{parts[1]}"
+ else:
+ package_name = import_source
+ return package_name, "external"
+
+ # Regular package import
+ package_name = import_source.split("/")[0]
+ return package_name, "external"
+
+ def _build_file_map(self, js_ts_files: List[Dict[str, Any]]):
+ """Build a map of file paths for resolving imports."""
+ for file_info in js_ts_files:
+ path = file_info["path"]
+ # Store with and without extension for resolution
+ self._file_map[path] = path
+ # Also store without extension
+ for ext in [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]:
+ if path.endswith(ext):
+ no_ext = path[: -len(ext)]
+ self._file_map[no_ext] = path
+ break
+ # Handle index files - map directory to index file
+ basename = os.path.basename(path)
+ if basename.startswith("index."):
+ dir_path = os.path.dirname(path)
+ if dir_path not in self._file_map:
+ self._file_map[dir_path] = path
+
+ def build_dependency_graph(self) -> Dict[str, Dict[str, Any]]:
+ """
+ Analyzes the entire repository and builds a dependency graph.
+
+ Returns:
+ A dictionary representing the dependency graph, where:
+ - Keys are module identifiers (file paths for internal, package names for external)
+ - Values are dictionaries containing:
+ - 'type': 'internal', 'node_builtin', or 'external'
+ - 'path': File path (for internal modules)
+ - 'dependencies': Set of module identifiers this module depends on
+ """
+ self.dependency_graph = {}
+ self._file_map = {}
+
+ # Parse package.json
+ self._package_json = self._parse_package_json()
+ if self._package_json:
+ self._package_name = self._package_json.get("name")
+
+ # Get all JS/TS files
+ file_tree = self.repo.get_file_tree()
+ js_ts_files = [
+ f
+ for f in file_tree
+ if f["path"].endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"))
+ and "node_modules" not in f["path"]
+ and not f["path"].endswith(".d.ts") # Skip declaration files
+ ]
+
+ self._build_file_map(js_ts_files)
+
+ # Process each file
+ for file_info in js_ts_files:
+ file_path = file_info["path"]
+
+ # Initialize node in graph
+ if file_path not in self.dependency_graph:
+ self.dependency_graph[file_path] = {
+ "type": "internal",
+ "path": file_path,
+ "dependencies": set(),
+ }
+
+ # Extract imports
+ imports = self._extract_imports_from_file(file_path)
+ for imp in imports:
+ source = imp["source"]
+ resolved, dep_type = self._resolve_import(source, file_path)
+
+ # For internal imports, try to resolve to actual file path
+ if dep_type == "internal":
+ actual_path = self._file_map.get(resolved)
+ if actual_path:
+ resolved = actual_path
+
+ # Add to dependencies
+ self.dependency_graph[file_path]["dependencies"].add(resolved)
+
+ # Add dependency node to graph if not present
+ if resolved not in self.dependency_graph:
+ self.dependency_graph[resolved] = {
+ "type": dep_type,
+ "path": resolved if dep_type == "internal" else None,
+ "dependencies": set(),
+ }
+
+ self._initialized = True
+ return self.dependency_graph
+
+ def export_dependency_graph(
+ self, output_format: str = "json", output_path: Optional[str] = None
+ ) -> Union[Dict, str]:
+ """
+ Export the dependency graph in various formats.
+
+ Args:
+ output_format: Format to export ('json', 'dot', 'graphml', 'adjacency')
+ output_path: Path to save the output file (if None, returns the data)
+
+ Returns:
+ Depending on format and output_path:
+ - If output_path is provided: Path to the output file
+ - If output_path is None: Formatted dependency data
+ """
+ if not self._initialized:
+ self.build_dependency_graph()
+
+ # Convert sets to lists for serialization
+ serializable_graph = {}
+ for module, data in self.dependency_graph.items():
+ serializable_graph[module] = {
+ "type": data["type"],
+ "path": data["path"],
+ "dependencies": list(data["dependencies"]),
+ }
+
+ if output_format == "json":
+ if output_path:
+ with open(output_path, "w") as f:
+ json.dump(serializable_graph, f, indent=2)
+ return output_path
+ return serializable_graph
+
+ elif output_format == "dot":
+ dot_content = self._generate_dot_file(serializable_graph)
+ if output_path:
+ with open(output_path, "w") as f:
+ f.write(dot_content)
+ return output_path
+ return dot_content
+
+ elif output_format == "graphml":
+ graphml_content = self._generate_graphml_file(serializable_graph)
+ if output_path:
+ with open(output_path, "w") as f:
+ f.write(graphml_content)
+ return output_path
+ return graphml_content
+
+ elif output_format == "adjacency":
+ adjacency_list = {}
+ for module, data in serializable_graph.items():
+ adjacency_list[module] = data["dependencies"]
+
+ if output_path:
+ with open(output_path, "w") as f:
+ json.dump(adjacency_list, f, indent=2)
+ return output_path
+ return adjacency_list
+
+ else:
+ raise ValueError(f"Unsupported output format: {output_format}")
+
+ def _generate_dot_file(self, graph: Dict[str, Dict[str, Any]]) -> str:
+ """Generate a DOT file for visualization with Graphviz."""
+ dot_lines = ["digraph G {", ' rankdir="LR";', " node [shape=box];"]
+
+ # Add nodes
+ for module, data in graph.items():
+ node_color = self._COLOR_MAP.get(data["type"], "white")
+ safe_module = module.replace('"', '\\"')
+ # Use short name for display
+ if "/" in module:
+ short_name = module.split("/")[-1]
+ else:
+ short_name = module
+ dot_lines.append(f' "{safe_module}" [label="{short_name}", style=filled, fillcolor={node_color}];')
+
+ # Add edges (only from internal modules to keep graph manageable)
+ for module, data in graph.items():
+ if data["type"] != "internal":
+ continue
+ safe_module = module.replace('"', '\\"')
+ for dep in data["dependencies"]:
+ if dep in graph:
+ safe_dep = dep.replace('"', '\\"')
+ dot_lines.append(f' "{safe_module}" -> "{safe_dep}";')
+
+ dot_lines.append("}")
+ return "\n".join(dot_lines)
+
+ def _generate_graphml_file(self, graph: Dict[str, Dict[str, Any]]) -> str:
+ """Generate a GraphML file for visualization."""
+ graphml_lines = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ ]
+
+ def xml_escape(s: str) -> str:
+ return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
+
+ # Add nodes
+ for module, data in graph.items():
+ safe_module = xml_escape(module)
+ graphml_lines.append(f' ')
+ graphml_lines.append(f' {data["type"]}')
+ if data["path"]:
+ safe_path = xml_escape(data["path"])
+ graphml_lines.append(f' {safe_path}')
+ graphml_lines.append(" ")
+
+ # Add edges
+ edge_id = 0
+ for module, data in graph.items():
+ safe_module = xml_escape(module)
+ for dep in data["dependencies"]:
+ if dep in graph:
+ safe_dep = xml_escape(dep)
+ graphml_lines.append(f' ')
+ edge_id += 1
+
+ graphml_lines.append("")
+ graphml_lines.append("")
+ return "\n".join(graphml_lines)
+
+ def find_cycles(self) -> List[List[str]]:
+ """
+ Find cycles in the dependency graph.
+
+ Returns:
+ List of cycles, where each cycle is a list of module identifiers
+ """
+ if not self._initialized:
+ self.build_dependency_graph()
+
+ cycles = []
+
+ for start_module in self.dependency_graph:
+ if self.dependency_graph[start_module]["type"] != "internal":
+ continue
+
+ path: List[str] = []
+ visited = set()
+
+ def dfs(module):
+ if module in path:
+ cycle_start = path.index(module)
+ cycle = [*path[cycle_start:], module]
+ if cycle not in cycles and len(cycle) > 1:
+ cycles.append(cycle)
+ return
+
+ if module in visited or module not in self.dependency_graph:
+ return
+
+ visited.add(module)
+ path.append(module)
+
+ for dep in self.dependency_graph[module]["dependencies"]:
+ if self.dependency_graph.get(dep, {}).get("type") == "internal":
+ dfs(dep)
+
+ path.pop()
+
+ dfs(start_module)
+
+ return cycles
+
+ def get_dependencies(self, item: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get dependencies for a specific component.
+
+ Args:
+ item: Path to the module file
+ include_indirect: Whether to include indirect dependencies
+
+ Returns:
+ List of module identifiers this module depends on
+ """
+ return self.get_module_dependencies(item, include_indirect)
+
+ def get_module_dependencies(self, module_path: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get dependencies for a specific module.
+
+ Args:
+ module_path: Path to the module file
+ include_indirect: Whether to include indirect dependencies
+
+ Returns:
+ List of module identifiers this module depends on
+ """
+ if not self._initialized:
+ self.build_dependency_graph()
+
+ if module_path not in self.dependency_graph:
+ return []
+
+ if include_indirect:
+ all_deps = set()
+ visited = set()
+
+ def dfs(module):
+ if module in visited or module not in self.dependency_graph:
+ return
+ visited.add(module)
+ for dep in self.dependency_graph[module]["dependencies"]:
+ if dep in self.dependency_graph:
+ all_deps.add(dep)
+ dfs(dep)
+
+ dfs(module_path)
+ return list(all_deps)
+ else:
+ return [dep for dep in self.dependency_graph[module_path]["dependencies"] if dep in self.dependency_graph]
+
+ def get_dependents(self, module_path: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get modules that depend on the specified module.
+
+ Args:
+ module_path: Path to the module file
+ include_indirect: Whether to include indirect dependents
+
+ Returns:
+ List of module identifiers that depend on this module
+ """
+ if not self._initialized:
+ self.build_dependency_graph()
+
+ if module_path not in self.dependency_graph:
+ return []
+
+ direct_dependents = [mod for mod, data in self.dependency_graph.items() if module_path in data["dependencies"]]
+
+ if not include_indirect:
+ return direct_dependents
+
+ all_dependents = set(direct_dependents)
+
+ def find_ancestors(module):
+ parents = [
+ m
+ for m, data in self.dependency_graph.items()
+ if module in data["dependencies"] and m not in all_dependents
+ ]
+ for parent in parents:
+ all_dependents.add(parent)
+ find_ancestors(parent)
+
+ for dep in direct_dependents:
+ find_ancestors(dep)
+
+ return list(all_dependents)
+
+ def visualize_dependencies(self, output_path: str, format: str = "png") -> str:
+ """
+ Generate a visualization of the dependency graph.
+
+ Note: Requires Graphviz to be installed.
+
+ Args:
+ output_path: Path to save the visualization
+ format: Output format ('png', 'svg', 'pdf')
+
+ Returns:
+ Path to the generated visualization file
+ """
+ try:
+ import graphviz
+ except ImportError:
+ raise ImportError(
+ "Graphviz Python package is required for visualization. "
+ "Install with 'pip install graphviz'. "
+ "You also need the Graphviz binary installed on your system."
+ )
+
+ if not self._initialized:
+ self.build_dependency_graph()
+
+ dot = graphviz.Digraph("dependencies", comment="Module Dependencies", format=format, engine="dot")
+ dot.attr(rankdir="LR")
+
+ for module, data in self.dependency_graph.items():
+ if data["type"] != "internal":
+ continue
+
+ # Use short name for label
+ if "/" in module:
+ label = module.split("/")[-1]
+ else:
+ label = module
+
+ dot.node(
+ module,
+ label=label,
+ tooltip=module,
+ style="filled",
+ fillcolor=self._COLOR_MAP.get(data["type"], "white"),
+ shape="box",
+ )
+
+ for module, data in self.dependency_graph.items():
+ if data["type"] != "internal":
+ continue
+
+ for dep in data["dependencies"]:
+ if dep in self.dependency_graph and self.dependency_graph[dep]["type"] == "internal":
+ dot.edge(module, dep)
+
+ dot.render(output_path, cleanup=True)
+ return f"{output_path}.{format}"
+
+ def generate_llm_context(
+ self, max_tokens: int = 4000, output_format: str = "markdown", output_path: Optional[str] = None
+ ) -> str:
+ """
+ Generate a JavaScript/TypeScript-specific description of the dependency graph.
+
+ Args:
+ max_tokens: Approximate maximum number of tokens in the output
+ output_format: Format of the output ('markdown', 'text')
+ output_path: Optional path to save the output to a file
+
+ Returns:
+ A string containing the natural language description
+ """
+ base_summary = super().generate_llm_context(max_tokens, output_format, None)
+
+ # Count by type
+ internal_count = len([m for m, d in self.dependency_graph.items() if d["type"] == "internal"])
+ builtin_count = len([m for m, d in self.dependency_graph.items() if d["type"] == "node_builtin"])
+ external_count = len([m for m, d in self.dependency_graph.items() if d["type"] == "external"])
+
+ # Find modules with most imports
+ heavy_importers = sorted(
+ [(m, len(d["dependencies"])) for m, d in self.dependency_graph.items() if d["type"] == "internal"],
+ key=lambda x: x[1],
+ reverse=True,
+ )[:5]
+
+ # Find most imported modules
+ import_counts: Dict[str, int] = {}
+ for m, d in self.dependency_graph.items():
+ if d["type"] == "internal":
+ for dep in d["dependencies"]:
+ import_counts[dep] = import_counts.get(dep, 0) + 1
+
+ most_imported = sorted(import_counts.items(), key=lambda x: x[1], reverse=True)[:5]
+
+ # Categorize external dependencies
+ external_deps = [m for m, d in self.dependency_graph.items() if d["type"] == "external"]
+
+ if output_format == "markdown":
+ parts = base_summary.split("## Additional Insights")
+
+ js_insights = ["## JavaScript/TypeScript-Specific Insights\n"]
+ if self._package_name:
+ js_insights.append(f"\n**Package:** `{self._package_name}`\n\n")
+
+ js_insights.append("### Dependency Types\n")
+ js_insights.append(f"- Internal modules: {internal_count}\n")
+ js_insights.append(f"- Node.js built-ins: {builtin_count}\n")
+ js_insights.append(f"- External packages: {external_count}\n\n")
+
+ if heavy_importers:
+ js_insights.append("### Modules with Most Imports\n")
+ for module, count in heavy_importers:
+ short_name = module.split("/")[-1] if "/" in module else module
+ js_insights.append(f"- **{short_name}** imports {count} modules\n")
+ js_insights.append("\n")
+
+ if most_imported:
+ js_insights.append("### Most Commonly Imported\n")
+ for module, count in most_imported:
+ module_type = self.dependency_graph.get(module, {}).get("type", "external")
+ short_name = module.split("/")[-1] if "/" in module else module
+ js_insights.append(f"- **{short_name}** ({module_type}) imported by {count} modules\n")
+ js_insights.append("\n")
+
+ if external_deps:
+ js_insights.append("### External Dependencies\n")
+ for dep in sorted(external_deps)[:10]:
+ js_insights.append(f"- `{dep}`\n")
+ if len(external_deps) > 10:
+ js_insights.append(f"- ...and {len(external_deps) - 10} more\n")
+ js_insights.append("\n")
+
+ result = parts[0] + "".join(js_insights) + "## Additional Insights" + parts[1]
+
+ else:
+ parts = base_summary.split("ADDITIONAL INSIGHTS:")
+
+ js_insights = ["JAVASCRIPT/TYPESCRIPT-SPECIFIC INSIGHTS:\n"]
+ js_insights.append("----------------------------------------\n\n")
+ if self._package_name:
+ js_insights.append(f"Package: {self._package_name}\n\n")
+
+ js_insights.append("Dependency Types:\n")
+ js_insights.append(f"- Internal modules: {internal_count}\n")
+ js_insights.append(f"- Node.js built-ins: {builtin_count}\n")
+ js_insights.append(f"- External packages: {external_count}\n\n")
+
+ if heavy_importers:
+ js_insights.append("Modules with Most Imports:\n")
+ for module, count in heavy_importers:
+ short_name = module.split("/")[-1] if "/" in module else module
+ js_insights.append(f"- {short_name} imports {count} modules\n")
+ js_insights.append("\n")
+
+ if most_imported:
+ js_insights.append("Most Commonly Imported:\n")
+ for module, count in most_imported:
+ module_type = self.dependency_graph.get(module, {}).get("type", "external")
+ short_name = module.split("/")[-1] if "/" in module else module
+ js_insights.append(f"- {short_name} ({module_type}) imported by {count} modules\n")
+ js_insights.append("\n")
+
+ if external_deps:
+ js_insights.append("External Dependencies:\n")
+ for dep in sorted(external_deps)[:10]:
+ js_insights.append(f"- {dep}\n")
+ if len(external_deps) > 10:
+ js_insights.append(f"- ...and {len(external_deps) - 10} more\n")
+ js_insights.append("\n")
+
+ result = parts[0] + "".join(js_insights) + "ADDITIONAL INSIGHTS:" + parts[1]
+
+ if output_path:
+ with open(output_path, "w") as f:
+ f.write(result)
+
+ return result
+
+ def generate_dependency_report(self, output_path: Optional[str] = None) -> Dict[str, Any]:
+ """
+ Generate a comprehensive dependency report for the repository.
+
+ Args:
+ output_path: Optional path to save the report JSON
+
+ Returns:
+ Dictionary with the complete dependency report
+ """
+ if not self._initialized:
+ self.build_dependency_graph()
+
+ internal_modules = [m for m, data in self.dependency_graph.items() if data["type"] == "internal"]
+ external_modules = [m for m, data in self.dependency_graph.items() if data["type"] == "external"]
+ builtin_modules = [m for m, data in self.dependency_graph.items() if data["type"] == "node_builtin"]
+
+ cycles = self.find_cycles()
+
+ high_dependency_modules = []
+ for module, data in self.dependency_graph.items():
+ if data["type"] == "internal":
+ dependents = self.get_dependents(module)
+ dependencies = self.get_module_dependencies(module)
+
+ if len(dependents) > 5 or len(dependencies) > 10:
+ high_dependency_modules.append(
+ {
+ "module": module,
+ "path": data["path"],
+ "dependent_count": len(dependents),
+ "dependency_count": len(dependencies),
+ }
+ )
+
+ report = {
+ "summary": {
+ "package_name": self._package_name,
+ "total_modules": len(self.dependency_graph),
+ "internal_modules": len(internal_modules),
+ "external_packages": len(external_modules),
+ "node_builtins": len(builtin_modules),
+ "dependency_cycles": len(cycles),
+ },
+ "cycles": cycles,
+ "high_dependency_modules": sorted(
+ high_dependency_modules, key=lambda x: x["dependent_count"] + x["dependency_count"], reverse=True
+ ),
+ "external_dependencies": sorted(external_modules),
+ "node_builtins_used": sorted(builtin_modules),
+ }
+
+ if output_path:
+ with open(output_path, "w") as f:
+ json.dump(report, f, indent=2)
+
+ return report
diff --git a/src/kit/dependency_analyzer/python_dependency_analyzer.py b/src/kit/dependency_analyzer/python_dependency_analyzer.py
index c7a4ecec..4fe1edbe 100644
--- a/src/kit/dependency_analyzer/python_dependency_analyzer.py
+++ b/src/kit/dependency_analyzer/python_dependency_analyzer.py
@@ -320,6 +320,19 @@ def dfs(module):
return cycles
+ def get_dependencies(self, item: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get dependencies for a specific component.
+
+ Args:
+ item: Name of the module to check
+ include_indirect: Whether to include indirect dependencies
+
+ Returns:
+ List of module names this module depends on
+ """
+ return self.get_module_dependencies(item, include_indirect)
+
def get_module_dependencies(self, module_name: str, include_indirect: bool = False) -> List[str]:
"""
Get dependencies for a specific module.
diff --git a/src/kit/dependency_analyzer/terraform_dependency_analyzer.py b/src/kit/dependency_analyzer/terraform_dependency_analyzer.py
index 4eef55a0..c4fbd67d 100644
--- a/src/kit/dependency_analyzer/terraform_dependency_analyzer.py
+++ b/src/kit/dependency_analyzer/terraform_dependency_analyzer.py
@@ -569,6 +569,19 @@ def dfs(node):
return cycles
+ def get_dependencies(self, item: str, include_indirect: bool = False) -> List[str]:
+ """
+ Get dependencies for a specific component.
+
+ Args:
+ item: ID of the resource to check
+ include_indirect: Whether to include indirect dependencies
+
+ Returns:
+ List of resource IDs this resource depends on
+ """
+ return self.get_resource_dependencies(item, include_indirect)
+
def get_resource_dependencies(self, resource_id: str, include_indirect: bool = False) -> List[str]:
"""
Get dependencies for a specific resource.
diff --git a/tests/perf/test_dependency_perf.py b/tests/perf/test_dependency_perf.py
index 00447ae7..0f36a173 100644
--- a/tests/perf/test_dependency_perf.py
+++ b/tests/perf/test_dependency_perf.py
@@ -290,6 +290,67 @@ def analyze():
shutil.rmtree(tmpdir, ignore_errors=True)
+def generate_javascript_repo(num_modules: int, imports_per_module: int = 5) -> str:
+ """Generate a synthetic JavaScript repo for benchmarking."""
+ tmpdir = tempfile.mkdtemp(prefix="kit_perf_js_")
+
+ # Create package.json
+ with open(f"{tmpdir}/package.json", "w") as f:
+ f.write('{"name": "benchmark-app", "version": "1.0.0"}\n')
+
+ os.makedirs(f"{tmpdir}/src")
+
+ # Create modules
+ for i in range(num_modules):
+ imports = []
+ # Add some external imports
+ imports.append("import path from 'path';")
+ imports.append("import fs from 'fs';")
+
+ # Add internal imports (to earlier modules)
+ for j in range(min(imports_per_module, i)):
+ imports.append(f"import {{ func{j} }} from './module_{j}.js';")
+
+ content = "\n".join(imports) + f"\n\nexport function func{i}() {{\n return {i};\n}}\n"
+
+ with open(f"{tmpdir}/src/module_{i}.js", "w") as f:
+ f.write(content)
+
+ return tmpdir
+
+
+def run_javascript_benchmark(num_modules: int, iterations: int = 5) -> PerfResult:
+ """Benchmark JavaScript dependency analysis."""
+ tmpdir = generate_javascript_repo(num_modules)
+
+ try:
+ repo = Repository(tmpdir)
+
+ def analyze():
+ analyzer = repo.get_dependency_analyzer("javascript")
+ analyzer.build_dependency_graph()
+
+ times = benchmark(analyze, iterations=iterations)
+
+ # Get some metadata
+ analyzer = repo.get_dependency_analyzer("javascript")
+ graph = analyzer.build_dependency_graph()
+
+ return PerfResult(
+ f"javascript_{num_modules}_modules",
+ times,
+ {
+ "num_modules": num_modules,
+ "graph_nodes": len(graph),
+ "language": "javascript",
+ },
+ )
+ finally:
+ import shutil
+
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
def run_real_repo_benchmark(repo_path: str, language: str, iterations: int = 3) -> PerfResult:
"""Benchmark against a real repository."""
repo = Repository(repo_path)
@@ -512,6 +573,60 @@ def analyze():
shutil.rmtree(tmpdir, ignore_errors=True)
+def test_javascript_10_modules(benchmark):
+ """Benchmark JavaScript analyzer with 10 modules."""
+ tmpdir = generate_javascript_repo(10)
+ try:
+ repo = Repository(tmpdir)
+
+ def analyze():
+ analyzer = repo.get_dependency_analyzer("javascript")
+ return analyzer.build_dependency_graph()
+
+ result = benchmark(analyze)
+ assert len(result) > 0
+ finally:
+ import shutil
+
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+def test_javascript_50_modules(benchmark):
+ """Benchmark JavaScript analyzer with 50 modules."""
+ tmpdir = generate_javascript_repo(50)
+ try:
+ repo = Repository(tmpdir)
+
+ def analyze():
+ analyzer = repo.get_dependency_analyzer("javascript")
+ return analyzer.build_dependency_graph()
+
+ result = benchmark(analyze)
+ assert len(result) > 0
+ finally:
+ import shutil
+
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+def test_javascript_100_modules(benchmark):
+ """Benchmark JavaScript analyzer with 100 modules."""
+ tmpdir = generate_javascript_repo(100)
+ try:
+ repo = Repository(tmpdir)
+
+ def analyze():
+ analyzer = repo.get_dependency_analyzer("javascript")
+ return analyzer.build_dependency_graph()
+
+ result = benchmark(analyze)
+ assert len(result) > 0
+ finally:
+ import shutil
+
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
# === File Tree Performance Tests (Rust-accelerated) ===
@@ -828,7 +943,7 @@ def extract():
help="Type of benchmark to run",
)
parser.add_argument(
- "--language", choices=["python", "go", "terraform", "all"], default="all", help="Language to benchmark (deps)"
+ "--language", choices=["python", "go", "terraform", "javascript", "all"], default="all", help="Language to benchmark (deps)"
)
parser.add_argument("--repo", type=str, help="Path to real repo to benchmark")
parser.add_argument("--repo-language", type=str, help="Language for real repo benchmark")
@@ -855,6 +970,10 @@ def extract():
print(f"Benchmarking Terraform dependency analyzer with {size} resources...")
results.append(run_terraform_benchmark(size, args.iterations))
+ if args.language in ("javascript", "all"):
+ print(f"Benchmarking JavaScript dependency analyzer with {size} modules...")
+ results.append(run_javascript_benchmark(size, args.iterations))
+
# File tree benchmarks (Rust-accelerated)
if args.benchmark in ("filetree", "all"):
print(f"Benchmarking file tree with {size} dirs x 10 files...")
diff --git a/tests/test_cli_dependencies.py b/tests/test_cli_dependencies.py
index 7b8704f7..d6d97616 100644
--- a/tests/test_cli_dependencies.py
+++ b/tests/test_cli_dependencies.py
@@ -51,6 +51,7 @@ def mock_repository():
analyzer.build_dependency_graph.return_value = mock_graph
analyzer.find_cycles.return_value = []
analyzer.export_dependency_graph.return_value = '{"mock": "data"}'
+ analyzer.get_dependencies.return_value = ["kit.utils", "pathlib"]
analyzer.get_module_dependencies.return_value = ["kit.utils", "pathlib"]
analyzer.get_dependents.return_value = []
analyzer.get_resource_dependencies.return_value = ["aws_vpc.main"]
@@ -177,7 +178,7 @@ def test_module_analysis_python(self, mock_repo_class, runner, mock_repository):
assert "kit.utils (internal)" in result.output
assert "pathlib (external)" in result.output
- analyzer.get_module_dependencies.assert_called_with("kit.repository", include_indirect=False)
+ analyzer.get_dependencies.assert_called_with("kit.repository", include_indirect=False)
analyzer.get_dependents.assert_called_with("kit.repository", include_indirect=False)
@patch("kit.Repository")
@@ -193,7 +194,7 @@ def test_module_analysis_with_indirect(self, mock_repo_class, runner, mock_repos
assert result.exit_code == 0
assert "All dependencies (2):" in result.output
- analyzer.get_module_dependencies.assert_called_with("kit.repository", include_indirect=True)
+ analyzer.get_dependencies.assert_called_with("kit.repository", include_indirect=True)
analyzer.get_dependents.assert_called_with("kit.repository", include_indirect=True)
@patch("kit.Repository")
@@ -207,7 +208,7 @@ def test_module_analysis_terraform(self, mock_repo_class, runner, mock_repositor
assert result.exit_code == 0
assert "Analyzing dependencies for: aws_instance.web" in result.output
- analyzer.get_resource_dependencies.assert_called_with("aws_instance.web", include_indirect=False)
+ analyzer.get_dependencies.assert_called_with("aws_instance.web", include_indirect=False)
@patch("kit.Repository")
def test_module_not_found(self, mock_repo_class, runner, mock_repository):
@@ -419,7 +420,7 @@ def test_short_option_flags(self, mock_repo_class, runner, mock_repository):
assert "Generating visualization" in result.output
assert "Analyzing dependencies for: kit.repository" in result.output
- analyzer.get_module_dependencies.assert_called_with("kit.repository", include_indirect=True)
+ analyzer.get_dependencies.assert_called_with("kit.repository", include_indirect=True)
class TestDependenciesIntegration:
diff --git a/tests/test_javascript_dependency_analyzer.py b/tests/test_javascript_dependency_analyzer.py
new file mode 100644
index 00000000..0b9620cc
--- /dev/null
+++ b/tests/test_javascript_dependency_analyzer.py
@@ -0,0 +1,588 @@
+import json
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from kit import Repository
+
+
+def test_javascript_dependency_analyzer_esm_basic():
+ """Test basic ESM import functionality."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # Create package.json
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "my-app", "version": "1.0.0"}, f)
+
+ # Create main.js with ESM imports
+ with open(f"{tmpdir}/main.js", "w") as f:
+ f.write("""import express from 'express';
+import { helper } from './utils/helper.js';
+
+const app = express();
+helper();
+""")
+
+ # Create utils/helper.js
+ os.makedirs(f"{tmpdir}/utils")
+ with open(f"{tmpdir}/utils/helper.js", "w") as f:
+ f.write("""import lodash from 'lodash';
+
+export function helper() {
+ return lodash.noop();
+}
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # Check internal files are found
+ assert "main.js" in graph
+ assert "utils/helper.js" in graph
+
+ # Check external packages are found
+ assert "express" in graph
+ assert "lodash" in graph
+
+ # Check dependencies
+ main_deps = graph["main.js"]["dependencies"]
+ assert "express" in main_deps
+
+
+def test_javascript_dependency_analyzer_cjs_require():
+ """Test CommonJS require() imports."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "cjs-app"}, f)
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""const express = require('express');
+const path = require('path');
+const { utils } = require('./lib/utils');
+
+module.exports = { app: express() };
+""")
+
+ os.makedirs(f"{tmpdir}/lib")
+ with open(f"{tmpdir}/lib/utils.js", "w") as f:
+ f.write("""const fs = require('fs');
+
+module.exports.utils = {
+ read: fs.readFileSync
+};
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # Check CJS imports are detected
+ assert "express" in graph
+ assert "path" in graph
+ assert "fs" in graph
+
+ # Check node builtins are classified correctly
+ assert graph["path"]["type"] == "node_builtin"
+ assert graph["fs"]["type"] == "node_builtin"
+
+
+def test_javascript_dependency_analyzer_typescript():
+ """Test TypeScript file analysis."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "ts-app"}, f)
+
+ with open(f"{tmpdir}/main.ts", "w") as f:
+ f.write("""import express, { Request, Response } from 'express';
+import { UserService } from './services/user';
+
+const app = express();
+
+app.get('/', (req: Request, res: Response) => {
+ const service = new UserService();
+});
+""")
+
+ os.makedirs(f"{tmpdir}/services")
+ with open(f"{tmpdir}/services/user.ts", "w") as f:
+ f.write("""import { Database } from './database';
+
+export class UserService {
+ private db = new Database();
+}
+""")
+
+ with open(f"{tmpdir}/services/database.ts", "w") as f:
+ f.write("""export class Database {
+ connect() {}
+}
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("typescript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # Check TypeScript files are found
+ assert "main.ts" in graph
+ assert "services/user.ts" in graph
+ assert "services/database.ts" in graph
+
+
+def test_javascript_dependency_analyzer_import_types():
+ """Test classification of imports as internal, node_builtin, or external."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "types-test", "dependencies": {"axios": "^1.0.0"}}, f)
+
+ with open(f"{tmpdir}/main.js", "w") as f:
+ f.write("""import axios from 'axios';
+import fs from 'fs';
+import path from 'node:path';
+import { helper } from './helper.js';
+
+axios.get('/api');
+""")
+
+ with open(f"{tmpdir}/helper.js", "w") as f:
+ f.write("""export function helper() {}
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # Check type classifications
+ assert graph["axios"]["type"] == "external"
+ assert graph["fs"]["type"] == "node_builtin"
+ assert graph["path"]["type"] == "node_builtin"
+ assert graph["main.js"]["type"] == "internal"
+ assert graph["helper.js"]["type"] == "internal"
+
+
+def test_javascript_dependency_analyzer_scoped_packages():
+ """Test handling of scoped packages (@org/pkg)."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump(
+ {
+ "name": "scoped-test",
+ "dependencies": {"@types/node": "^18.0.0", "@babel/core": "^7.0.0"},
+ },
+ f,
+ )
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""import { transform } from '@babel/core';
+import parser from '@babel/parser';
+import * as t from '@babel/types';
+
+transform(code);
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # Scoped packages should be detected
+ assert "@babel/core" in graph
+ assert "@babel/parser" in graph
+ assert "@babel/types" in graph
+
+ # All scoped packages are external
+ assert graph["@babel/core"]["type"] == "external"
+
+
+def test_javascript_dependency_analyzer_cycles():
+ """Test cycle detection in JavaScript modules."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "cycle-test"}, f)
+
+ with open(f"{tmpdir}/a.js", "w") as f:
+ f.write("""import { b } from './b.js';
+export const a = () => b();
+""")
+
+ with open(f"{tmpdir}/b.js", "w") as f:
+ f.write("""import { c } from './c.js';
+export const b = () => c();
+""")
+
+ with open(f"{tmpdir}/c.js", "w") as f:
+ f.write("""import { a } from './a.js';
+export const c = () => a();
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ analyzer.build_dependency_graph()
+ cycles = analyzer.find_cycles()
+
+ assert len(cycles) > 0
+
+ # Check that we found the cycle
+ found_cycle = False
+ for cycle in cycles:
+ file_names = [os.path.basename(p) for p in cycle]
+ if "a.js" in file_names and "b.js" in file_names and "c.js" in file_names:
+ found_cycle = True
+ break
+
+ assert found_cycle, "Expected cycle between a.js, b.js, and c.js was not found"
+
+
+def test_javascript_dependency_analyzer_export_json():
+ """Test JSON export of dependency graph."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "export-test"}, f)
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""import express from 'express';
+const app = express();
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ result = analyzer.export_dependency_graph(output_format="json")
+
+ assert isinstance(result, dict)
+ assert "index.js" in result
+ assert "express" in result
+ assert isinstance(result["index.js"]["dependencies"], list)
+
+
+def test_javascript_dependency_analyzer_export_dot():
+ """Test DOT format export."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "dot-test"}, f)
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""import './helper.js';
+""")
+
+ with open(f"{tmpdir}/helper.js", "w") as f:
+ f.write("""export default {};
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ result = analyzer.export_dependency_graph(output_format="dot")
+
+ assert isinstance(result, str)
+ assert "digraph G" in result
+
+
+def test_javascript_dependency_analyzer_mixed_imports():
+ """Test file with mixed ESM and CJS imports."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "mixed-test"}, f)
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""import express from 'express';
+const lodash = require('lodash');
+import('./dynamic.js').then(mod => mod.default());
+
+const app = express();
+lodash.noop();
+""")
+
+ with open(f"{tmpdir}/dynamic.js", "w") as f:
+ f.write("""export default function() {}
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # All import types should be detected
+ assert "express" in graph
+ assert "lodash" in graph
+
+ index_deps = graph["index.js"]["dependencies"]
+ assert "express" in index_deps
+ assert "lodash" in index_deps
+
+
+def test_javascript_dependency_analyzer_get_dependents():
+ """Test getting modules that depend on a given module."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "dependents-test"}, f)
+
+ with open(f"{tmpdir}/shared.js", "w") as f:
+ f.write("""export const shared = {};
+""")
+
+ with open(f"{tmpdir}/a.js", "w") as f:
+ f.write("""import { shared } from './shared.js';
+export const a = shared;
+""")
+
+ with open(f"{tmpdir}/b.js", "w") as f:
+ f.write("""import { shared } from './shared.js';
+export const b = shared;
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ analyzer.build_dependency_graph()
+ dependents = analyzer.get_dependents("shared.js")
+
+ assert "a.js" in dependents
+ assert "b.js" in dependents
+
+
+def test_javascript_dependency_analyzer_llm_context():
+ """Test LLM context generation."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "context-test", "dependencies": {"react": "^18.0.0"}}, f)
+
+ with open(f"{tmpdir}/App.js", "w") as f:
+ f.write("""import React from 'react';
+import { Component } from './Component.js';
+
+export function App() {
+ return ;
+}
+""")
+
+ with open(f"{tmpdir}/Component.js", "w") as f:
+ f.write("""import React from 'react';
+
+export function Component() {
+ return
Hello
;
+}
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ context = analyzer.generate_llm_context(output_format="markdown")
+
+ assert "# Dependency Analysis Summary" in context
+ assert "JavaScript/TypeScript-Specific Insights" in context
+
+
+def test_javascript_dependency_analyzer_factory():
+ """Test that the factory method correctly returns JavaScriptDependencyAnalyzer."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "factory-test"}, f)
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("export default {};")
+
+ repo = Repository(tmpdir)
+
+ # Test all aliases work
+ from kit.dependency_analyzer.javascript_dependency_analyzer import JavaScriptDependencyAnalyzer
+
+ for lang in ["javascript", "typescript", "js", "ts"]:
+ analyzer = repo.get_dependency_analyzer(lang)
+ assert isinstance(analyzer, JavaScriptDependencyAnalyzer)
+
+
+def test_javascript_dependency_analyzer_no_package_json():
+ """Test analyzer behavior when package.json is missing."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""import express from 'express';
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ # Should not raise, just work without package name
+ graph = analyzer.build_dependency_graph()
+
+ assert "index.js" in graph
+ assert "express" in graph
+
+
+def test_javascript_dependency_analyzer_export_from():
+ """Test re-export statements."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "reexport-test"}, f)
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""export { foo } from './foo.js';
+export * from './bar.js';
+""")
+
+ with open(f"{tmpdir}/foo.js", "w") as f:
+ f.write("""export const foo = 'foo';
+""")
+
+ with open(f"{tmpdir}/bar.js", "w") as f:
+ f.write("""export const bar = 'bar';
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # Re-exports should be detected as dependencies
+ index_deps = graph["index.js"]["dependencies"]
+ # Check that foo.js and bar.js paths are in dependencies
+ assert any("foo" in dep for dep in index_deps)
+ assert any("bar" in dep for dep in index_deps)
+
+
+def test_javascript_dependency_analyzer_jsx_tsx():
+ """Test JSX and TSX file handling."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "jsx-test"}, f)
+
+ with open(f"{tmpdir}/App.jsx", "w") as f:
+ f.write("""import React from 'react';
+import { Header } from './Header.tsx';
+
+export function App() {
+ return ;
+}
+""")
+
+ with open(f"{tmpdir}/Header.tsx", "w") as f:
+ f.write("""import React from 'react';
+
+interface Props {
+ title: string;
+}
+
+export function Header({ title }: Props) {
+ return {title}
;
+}
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ assert "App.jsx" in graph
+ assert "Header.tsx" in graph
+
+
+def test_javascript_dependency_analyzer_node_modules_excluded():
+ """Test that node_modules directory is excluded."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "exclude-test"}, f)
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""import express from 'express';
+""")
+
+ # Create a fake node_modules with express
+ os.makedirs(f"{tmpdir}/node_modules/express")
+ with open(f"{tmpdir}/node_modules/express/index.js", "w") as f:
+ f.write("""module.exports = function() {};
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # node_modules files should not be in the graph as internal modules
+ internal_files = [m for m, d in graph.items() if d["type"] == "internal"]
+ assert not any("node_modules" in f for f in internal_files)
+
+
+def test_javascript_dependency_analyzer_index_resolution():
+ """Test that directory imports resolve to index files."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "index-test"}, f)
+
+ with open(f"{tmpdir}/main.js", "w") as f:
+ f.write("""import { utils } from './lib';
+""")
+
+ os.makedirs(f"{tmpdir}/lib")
+ with open(f"{tmpdir}/lib/index.js", "w") as f:
+ f.write("""export const utils = {};
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ graph = analyzer.build_dependency_graph()
+
+ # The index file should be in the graph
+ assert "lib/index.js" in graph
+
+
+def test_javascript_dependency_analyzer_get_dependencies_generic():
+ """Test the generic get_dependencies method."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump({"name": "generic-test"}, f)
+
+ with open(f"{tmpdir}/main.js", "w") as f:
+ f.write("""import { helper } from './helper.js';
+import express from 'express';
+""")
+
+ with open(f"{tmpdir}/helper.js", "w") as f:
+ f.write("""export function helper() {}
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ analyzer.build_dependency_graph()
+
+ # Test generic get_dependencies method
+ deps = analyzer.get_dependencies("main.js")
+ assert "express" in deps or "helper.js" in deps
+
+ # Should be same as get_module_dependencies
+ module_deps = analyzer.get_module_dependencies("main.js")
+ assert deps == module_deps
+
+
+def test_javascript_dependency_analyzer_dependency_report():
+ """Test dependency report generation."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(f"{tmpdir}/package.json", "w") as f:
+ json.dump(
+ {"name": "report-test", "dependencies": {"express": "^4.0.0", "lodash": "^4.0.0"}},
+ f,
+ )
+
+ with open(f"{tmpdir}/index.js", "w") as f:
+ f.write("""import express from 'express';
+import lodash from 'lodash';
+import fs from 'fs';
+""")
+
+ repo = Repository(tmpdir)
+ analyzer = repo.get_dependency_analyzer("javascript")
+
+ report = analyzer.generate_dependency_report()
+
+ assert "summary" in report
+ assert report["summary"]["package_name"] == "report-test"
+ assert "external_dependencies" in report
+ assert "node_builtins_used" in report
+ assert "fs" in report["node_builtins_used"]