Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ uses [Semantic Versioning](https://semver.org/).
impl/trait methods become components, with trait-impl, field-type,
struct-literal, and call edges. `#[cfg(test)]` modules are skipped, and
`build.rs` is classified as a build artifact.
- **Dart / Flutter analyzer.** Classes, mixins, extensions, enums, top-level
functions and getters, and methods become components, with inheritance,
field-type, instantiation and call edges. `package:`/relative imports,
exports and `part` files decide which definition a name resolves to, and
library-private names never resolve outside their library; names not
visible through imports are dropped rather than guessed. Without a
`pubspec.yaml` in the analyzed tree, `package:` imports cannot be resolved,
so Dart calls fall back to name matching across Dart files (with a
warning). Flutter-aware:
widgets and State classes are typed, instantiations in `build()` become
widget-to-widget edges, `ref.watch`/`ref.read` link widgets to Riverpod
providers (including `@riverpod` codegen), and GoRouter routes link to their
screens. Generated `*.g.dart`/`*.freezed.dart` files and `.dart_tool/` are
ignored, and Dart modules get Flutter-specific documentation guidance.

### Changed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Full list in the [CHANGELOG](./CHANGELOG.md).
## Features

**Languages.** Python, Java, JavaScript, TypeScript, C, C++, C#, Kotlin,
PHP, Ruby, Scala, Rust.
PHP, Ruby, Scala, Rust, Dart (Flutter-aware).

**Hierarchical decomposition.** The dependency graph is clustered into a
module tree, recursively, so a 1.4M-line repository gets the same treatment
Expand Down
2 changes: 1 addition & 1 deletion codewiki/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def cli(ctx):

AI agents write the documentation from your dependency graph. Supports
Python, Java, JavaScript, TypeScript, C, C++, C#, Kotlin, PHP, Ruby,
Scala, and Rust.
Scala, Rust, and Dart.
"""
# Ensure context object exists
ctx.ensure_object(dict)
Expand Down
3 changes: 2 additions & 1 deletion codewiki/cli/utils/repo_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
".scala", # Scala
".sc", # Scala Scripts
".rs", # Rust
".dart", # Dart
}


Expand Down Expand Up @@ -65,7 +66,7 @@ def validate_repository(repo_path: Path) -> tuple[Path, list[tuple[str, int]]]:
raise RepositoryError(
f"No supported code files found in {repo_path}\n\n"
"CodeWiki supports: Python, Java, JavaScript, TypeScript, C, C++, C#, Kotlin, PHP, "
"Ruby, Scala, Rust\n\n"
"Ruby, Scala, Rust, Dart\n\n"
"Please navigate to a code repository or specify a custom directory:\n"
" cd /path/to/your/project\n"
" codewiki generate"
Expand Down
1 change: 1 addition & 0 deletions codewiki/cli/utils/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ def detect_supported_languages(directory: Path) -> list[tuple[str, int]]:
"Ruby": [".rb"],
"Scala": [".scala", ".sc"],
"Rust": [".rs"],
"Dart": [".dart"],
}

# Directories to exclude from counting
Expand Down
1 change: 1 addition & 0 deletions codewiki/mcp/tools/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ def _detect_via_mtime(
".scala",
".sc",
".rs",
".dart",
}

changed: list[str] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ def _filter_supported_languages(self, code_files: list[dict]) -> list[dict]:
Filter code files to only include supported languages.

Supports Python, JavaScript, TypeScript, Java, C#, C, C++, PHP, Ruby, Kotlin,
Scala, and Rust.
Scala, Rust, and Dart.
"""
supported_languages = {
"python",
Expand All @@ -356,6 +356,7 @@ def _filter_supported_languages(self, code_files: list[dict]) -> list[dict]:
"rust",
"kotlin",
"scala",
"dart",
}

return [
Expand All @@ -379,6 +380,7 @@ def _get_supported_languages(self) -> list[str]:
"kotlin",
"scala",
"rust",
"dart",
]

def _cleanup_repository(self, temp_dir: str):
Expand Down
169 changes: 168 additions & 1 deletion codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ class CallGraphAnalyzer:
def __init__(self):
"""Initialize the call graph analyzer."""
self.functions: dict[str, Node] = {}
self._dart_directives: dict[str, list] = {}
self._dart_visible: dict[str, set[str]] = {}
self._dart_library_members: dict[str, set[str]] = {}
self._dart_unscoped = False
self.call_relationships: list[CallRelationship] = []
self._python_project_modules: set = set()
self._python_external_import_roots: set = set()
Expand All @@ -85,6 +89,10 @@ def analyze_code_files(self, code_files: list[dict], base_dir: str) -> dict:
code_files = self._route_contextual_headers(code_files, base_dir)
self._python_project_modules = self._collect_python_modules(code_files)
self._python_external_import_roots = set()
self._dart_directives = {}
self._dart_visible = {}
self._dart_library_members = {}
self._dart_unscoped = False

files_analyzed = 0
files_failed = 0
Expand Down Expand Up @@ -116,6 +124,9 @@ def analyze_code_files(self, code_files: list[dict], base_dir: str) -> dict:
f"{files_failed} failed, {len(self.functions)} functions, {len(self.call_relationships)} relationships ({elapsed_time:.1f}s)"
)

if self._dart_directives:
self._build_dart_scopes(base_dir)

logger.debug("Resolving call relationships")
self._resolve_call_relationships()
self._deduplicate_relationships()
Expand Down Expand Up @@ -261,6 +272,8 @@ def _analyze_code_file(self, repo_dir: str, file_info: dict):
self._analyze_scala_file(file_path, content, repo_dir)
elif language == "rust":
self._analyze_rust_file(file_path, content, repo_dir)
elif language == "dart":
self._analyze_dart_file(file_path, content, repo_dir)
# else:
# logger.warning(
# f"Unsupported language for call graph analysis: {language} for file {file_path}"
Expand Down Expand Up @@ -560,6 +573,136 @@ def _analyze_rust_file(self, file_path: str, content: str, repo_dir: str):
except Exception:
logger.exception(f"Failed to analyze Rust file {file_path}")

def _analyze_dart_file(self, file_path: str, content: str, repo_dir: str):
"""
Analyze Dart file using tree-sitter based analyzer.

Besides components and relationships, records the file's
import/export/part directives for Dart scope-aware resolution.

Args:
file_path: Path to the Dart file
content: File content string
repo_dir: Repository base directory
"""
from codewiki.src.be.dependency_analyzer.analyzers.dart import TreeSitterDartAnalyzer

try:
analyzer = TreeSitterDartAnalyzer(str(file_path), content, repo_path=repo_dir)

for func in analyzer.nodes:
func_id = func.id if func.id else f"{file_path}:{func.name}"
self.functions[func_id] = func

self.call_relationships.extend(analyzer.call_relationships)
self._dart_directives[analyzer.relative_path] = analyzer.directives
except Exception:
logger.exception(f"Failed to analyze Dart file {file_path}")

def _build_dart_scopes(self, base_dir: str) -> None:
"""Resolve recorded Dart directives into per-file visibility scopes."""
from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import (
DartPackageResolver,
build_scopes,
)

files = set(self._dart_directives)
resolver = DartPackageResolver.from_files(base_dir, files)
scopes = build_scopes(self._dart_directives, resolver, files)
self._dart_library_members = scopes.library_members
if resolver.packages:
self._dart_visible = scopes.visible
return
# Without a pubspec.yaml (e.g. only `lib/` was analyzed) `package:`
# imports resolve to nothing, and scoping would drop nearly every
# cross-file edge. Match names across all Dart files instead.
self._dart_unscoped = True
logger.warning(
"No pubspec.yaml found for the analyzed Dart files; `package:` imports "
"cannot be resolved, so Dart calls are matched by name across Dart files."
)

def _dart_scope_candidates(
self, func_ids: list[str], scope: set[str] | None, caller_file: str | None = None
) -> list[str]:
from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import (
is_private_dart_name,
)

own_library = self._dart_library_members.get(caller_file, set()) if caller_file else set()
candidates: list[str] = []
for func_id in func_ids:
func = self.functions.get(func_id)
if func_id in candidates or func is None:
continue
func_file = Path(func.relative_path).as_posix()
if scope is not None and func_file not in scope:
continue
# A `_`-prefixed segment in the target (`_Ticker.tick`) is private
# to its library, whatever the callee's own spelling.
if is_private_dart_name(func.name) and func_file not in own_library:
continue
candidates.append(func_id)
return candidates

def _resolve_dart_callee(
self, callee: str, caller: Node, lang_indexes: dict | None
) -> str | None:
"""Prefer definitions the caller's library can see (imports, parts,
re-exports); library-private names only resolve inside the library."""
from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import (
is_private_dart_name,
)

if not lang_indexes or "::" in callee:
return None
caller_file = Path(caller.relative_path).as_posix()
scope: set[str] | None
if is_private_dart_name(callee):
scope = self._dart_library_members.get(caller_file, {caller_file})
elif self._dart_unscoped:
scope = None # any Dart file
else:
scope = self._dart_visible.get(caller_file)
if not scope:
return None
keys = [callee]
if "." in callee:
keys.append(callee.split(".")[0])
for key in keys:
# Exact names first: an imported `helper()` must not turn ambiguous
# because some visible class also has a `helper` method.
for index in ("exact", "simple"):
candidates = self._dart_scope_candidates(
lang_indexes[index].get(key, []), scope, caller_file
)
if len(candidates) == 1:
return candidates[0]
if candidates:
break

from codewiki.src.be.dependency_analyzer.analyzers.dart_flutter import (
RIVERPOD_NOTIFIER_TYPE,
RIVERPOD_PROVIDER_TYPE,
provider_alias_candidates,
)

# riverpod_generator providers live in ignored *.g.dart files: map
# `fooProvider` to the @riverpod declaration it was generated from.
for alias in provider_alias_candidates(callee):
candidates = [
func_id
for func_id in self._dart_scope_candidates(
lang_indexes["exact"].get(alias, []), scope, caller_file
)
if self.functions[func_id].node_type
in (RIVERPOD_PROVIDER_TYPE, RIVERPOD_NOTIFIER_TYPE)
and self.functions[func_id].name == alias
]
if len(candidates) == 1:
return candidates[0]
return None

def _resolve_call_relationships(self):
"""
Resolve function call relationships across all languages.
Expand Down Expand Up @@ -590,13 +733,21 @@ def _resolve_call_relationships(self):
relationship
for relationship in self.call_relationships
if relationship.is_resolved
or not self._is_external_callee(
or not self._is_unresolvable_dart(relationship)
and not self._is_external_callee(
self._caller_language(relationship.caller),
relationship.callee,
dotted_packages,
)
]

def _is_unresolvable_dart(self, relationship: CallRelationship) -> bool:
"""A Dart caller's callee that Dart resolution rejected is external or
invisible; drop it so ast_parser's name-only fallback cannot bind it
to an unrelated component."""
caller = self.functions.get(relationship.caller)
return caller is not None and caller.language == "dart"

def _dotted_project_packages(self) -> dict[str, set]:
"""Project packages/namespaces, partitioned by language. Java and C#
share the namespace-origin rule: a dotted callee qualified to a package
Expand Down Expand Up @@ -637,6 +788,16 @@ def _is_external_callee(
return True
if language == "python" and "." in callee:
return self._is_external_python_callee(callee)
if language == "dart":
from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import (
is_private_dart_name,
)

# A library-private name that did not resolve inside its library
# can never be a component elsewhere; dropping it keeps the
# name-based fallback from binding another library's `_Body`.
if is_private_dart_name(callee):
return True
return False

def _is_external_python_callee(self, callee: str) -> bool:
Expand Down Expand Up @@ -721,6 +882,10 @@ def _resolve_callee(
caller_language = caller.language if caller else None

lang_indexes = indexes["by_lang"].get(caller_language) if caller_language else None
if caller_language == "dart" and caller is not None:
# Dart resolution is authoritative: it never falls back to name
# guessing in other languages or invisible libraries.
return self._resolve_dart_callee(relationship.callee, caller, lang_indexes)
if lang_indexes:
match = self._resolve_callee_in(
relationship, lang_indexes["exact"], lang_indexes["simple"]
Expand Down Expand Up @@ -860,6 +1025,8 @@ def _generate_visualization_data(self) -> dict:
node_classes.append("lang-scala")
elif file_ext == ".rs":
node_classes.append("lang-rust")
elif file_ext == ".dart":
node_classes.append("lang-dart")

cytoscape_elements.append(
{
Expand Down
Loading
Loading