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"]