diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c3bb5f..074ab2be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ uses [Semantic Versioning](https://semver.org/). 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. +- **Go analyzer.** Functions, methods, structs, interfaces (and their method + signatures), and other defined types (`type Kind int`) become components. + Methods are keyed by their receiver type (`Buffer.Push`, pointer or value + receiver alike), whichever file of the package declares them. Edges cover + calls (plain, `pkg.F()`, and method calls on receivers, parameters and + locals whose type can be inferred), struct field and embedded types, + composite literals, types named in signatures and local `var`s, and types + that implement an interface of their own package. Names resolve inside the + package (directory) they name; imports of the repository's own packages map + to directories through the module path in `go.mod` (and local `replace` + directives), and standard-library or third-party calls are dropped. + `_test.go` files and `vendor/` are skipped. ### Changed diff --git a/README.md b/README.md index 95a6edfa..cfc8318b 100644 --- a/README.md +++ b/README.md @@ -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, Dart (Flutter-aware). +PHP, Ruby, Scala, Rust, Dart (Flutter-aware), Go. **Hierarchical decomposition.** The dependency graph is clustered into a module tree, recursively, so a 1.4M-line repository gets the same treatment diff --git a/codewiki/cli/main.py b/codewiki/cli/main.py index 254c360d..57f5391b 100644 --- a/codewiki/cli/main.py +++ b/codewiki/cli/main.py @@ -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, Rust, and Dart. + Scala, Rust, Dart, and Go. """ # Ensure context object exists ctx.ensure_object(dict) diff --git a/codewiki/cli/utils/repo_validator.py b/codewiki/cli/utils/repo_validator.py index 63da2646..e32dc75c 100644 --- a/codewiki/cli/utils/repo_validator.py +++ b/codewiki/cli/utils/repo_validator.py @@ -35,6 +35,7 @@ ".sc", # Scala Scripts ".rs", # Rust ".dart", # Dart + ".go", # Go } @@ -66,7 +67,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, Dart\n\n" + "Ruby, Scala, Rust, Dart, Go\n\n" "Please navigate to a code repository or specify a custom directory:\n" " cd /path/to/your/project\n" " codewiki generate" diff --git a/codewiki/cli/utils/validation.py b/codewiki/cli/utils/validation.py index 315aa9b2..87777cc2 100644 --- a/codewiki/cli/utils/validation.py +++ b/codewiki/cli/utils/validation.py @@ -169,6 +169,7 @@ def detect_supported_languages(directory: Path) -> list[tuple[str, int]]: "Scala": [".scala", ".sc"], "Rust": [".rs"], "Dart": [".dart"], + "Go": [".go"], } # Directories to exclude from counting diff --git a/codewiki/mcp/tools/analysis.py b/codewiki/mcp/tools/analysis.py index 3a4f8df5..1b2b2162 100644 --- a/codewiki/mcp/tools/analysis.py +++ b/codewiki/mcp/tools/analysis.py @@ -230,6 +230,7 @@ def _detect_via_mtime( ".sc", ".rs", ".dart", + ".go", } changed: list[str] = [] diff --git a/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py b/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py index 861ab97b..413aef01 100644 --- a/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py +++ b/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py @@ -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, Rust, and Dart. + Scala, Rust, Dart, and Go. """ supported_languages = { "python", @@ -381,6 +381,7 @@ def _get_supported_languages(self) -> list[str]: "scala", "rust", "dart", + "go", ] def _cleanup_repository(self, temp_dir: str): diff --git a/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py b/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py index b95c93f8..b6bf63c8 100644 --- a/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py +++ b/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py @@ -73,6 +73,8 @@ def __init__(self): self._dart_visible: dict[str, set[str]] = {} self._dart_library_members: dict[str, set[str]] = {} self._dart_unscoped = False + self._go_visible: dict[str, set[str]] = {} + self._go_index = None self.call_relationships: list[CallRelationship] = [] self._python_project_modules: set = set() self._python_external_import_roots: set = set() @@ -100,6 +102,8 @@ def analyze_code_files(self, code_files: list[dict], base_dir: str) -> dict: self._dart_visible = {} self._dart_library_members = {} self._dart_unscoped = False + self._go_visible = {} + self._go_index = None files_analyzed = 0 files_failed = 0 @@ -281,6 +285,8 @@ def _analyze_code_file(self, repo_dir: str, file_info: dict): self._analyze_rust_file(file_path, content, repo_dir) elif language == "dart": self._analyze_dart_file(file_path, content, repo_dir) + elif language == "go": + self._analyze_go_file(file_path, content, repo_dir) # else: # logger.warning( # f"Unsupported language for call graph analysis: {language} for file {file_path}" @@ -606,6 +612,47 @@ def _analyze_dart_file(self, file_path: str, content: str, repo_dir: str): except Exception: logger.exception(f"Failed to analyze Dart file {file_path}") + def _analyze_go_file(self, file_path: str, content: str, repo_dir: str): + """ + Analyze Go file using tree-sitter based analyzer. + + Besides components and relationships, records the package directories + the file can see (its own and the repository packages it imports) for + Go package-scoped resolution. + + Args: + file_path: Path to the Go file + content: File content string + repo_dir: Repository base directory + """ + from codewiki.src.be.dependency_analyzer.analyzers.go import TreeSitterGoAnalyzer + + try: + analyzer = TreeSitterGoAnalyzer(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._go_visible[analyzer.relative_path] = analyzer.visible_dirs + except Exception: + logger.exception(f"Failed to analyze Go file {file_path}") + + def _resolve_go_callee(self, callee: str, caller: Node) -> str | None: + """Go names resolve inside the package they name (a directory), or, + for a method on a value of unknown type, among the packages the + caller's file can see.""" + from codewiki.src.be.dependency_analyzer.analyzers.go import ( + GoPackageIndex, + resolve_go_callee, + ) + + if self._go_index is None: + self._go_index = GoPackageIndex(self.functions) + caller_file = Path(caller.relative_path).as_posix() + return resolve_go_callee(callee, caller, self._go_index, self._go_visible.get(caller_file)) + 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 ( @@ -723,6 +770,7 @@ def _resolve_call_relationships(self): func_info.language = CODE_EXTENSIONS.get(file_ext) indexes = self._build_resolution_indexes() + self._go_index = None resolved_count = 0 for relationship in self.call_relationships: @@ -740,7 +788,7 @@ def _resolve_call_relationships(self): relationship for relationship in self.call_relationships if relationship.is_resolved - or not self._is_unresolvable_dart(relationship) + or not self._has_authoritative_resolution(relationship) and not self._is_external_callee( self._caller_language(relationship.caller), relationship.callee, @@ -748,12 +796,19 @@ def _resolve_call_relationships(self): ) ] - 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.""" + if any(func.language == "go" for func in self.functions.values()): + from codewiki.src.be.dependency_analyzer.analyzers.go import link_go_interfaces + + # Go types implement interfaces implicitly: link them once every + # method of the package is known. + self.call_relationships.extend(link_go_interfaces(self.functions)) + + def _has_authoritative_resolution(self, relationship: CallRelationship) -> bool: + """A Dart or Go caller's callee that the language's scoped 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" + return caller is not None and caller.language in ("dart", "go") def _dotted_project_packages(self) -> dict[str, set]: """Project packages/namespaces, partitioned by language. Java and C# @@ -893,6 +948,9 @@ def _resolve_callee( # 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 caller_language == "go" and caller is not None: + # So is Go's: a name resolves only in the package it names. + return self._resolve_go_callee(relationship.callee, caller) if lang_indexes: match = self._resolve_callee_in( relationship, lang_indexes["exact"], lang_indexes["simple"] @@ -1034,6 +1092,8 @@ def _generate_visualization_data(self) -> dict: node_classes.append("lang-rust") elif file_ext == ".dart": node_classes.append("lang-dart") + elif file_ext == ".go": + node_classes.append("lang-go") cytoscape_elements.append( { diff --git a/codewiki/src/be/dependency_analyzer/analyzers/go.py b/codewiki/src/be/dependency_analyzer/analyzers/go.py new file mode 100644 index 00000000..e09a1388 --- /dev/null +++ b/codewiki/src/be/dependency_analyzer/analyzers/go.py @@ -0,0 +1,1273 @@ +"""Tree-sitter based Go analyzer. + +Extracts functions, methods, structs, interfaces (with their method +signatures), and other defined types (`type Kind int`) as documentable +components, plus embedding, field-type, composite-literal instantiation, +call, and interface-implementation dependency edges. + +Component types follow the same two-level `component_type` / `node_type` +split as the Rust analyzer: `component_type` is the coarse type the +pipeline gates leaf selection on (structs stay "struct", interfaces +"interface", other defined types map to "class"), while `node_type` and +`display_name` preserve the faithful Go construct. + +Methods are keyed by their receiver's base type (`Buffer.Push` for both +`func (b *Buffer) Push` and `func (b Buffer) Push`), no matter which file +of the package declares them. Owners are joined with `.` rather than `::` +because component ids already use `::` as the path separator. + +A Go package is a directory, so a name that is not defined in the file +itself is emitted as a *package reference*, `"/*.go::"` (the +repository root is `"*.go::"`): "the declaration `Name` in some file +of the package in ``". Imports of the repository's own packages are +mapped to directories through the module path in `go.mod` (and local +`replace` directives); calls into the standard library or third-party +modules are dropped. `CallGraphAnalyzer` resolves package references +against the components of that directory (`resolve_go_callee`) and links +types to the interfaces of their package that they implement +(`link_go_interfaces`). + +Known limitations: +- `_test.go` files and anything under `vendor/` produce no components, the + way the Rust analyzer skips `#[cfg(test)]` modules. +- Local variable types are inferred from declarations, composite literals, + `new(T)`, and same-file function results only; a method call on a value + of unknown type falls back to its method name, which resolves only when + exactly one method of that name exists in the caller's package or the + repository packages it imports. +- Methods promoted through embedding resolve for embedded types of the + same package only; dot imports are not followed. +- Type aliases (`type A = B`) are not components. +""" + +import logging +import os +import posixpath +import re +from collections import defaultdict +from functools import lru_cache +from pathlib import Path + +from tree_sitter_language_pack import get_parser + +from codewiki.src.be.dependency_analyzer.models.core import CallRelationship, Node + +logger = logging.getLogger(__name__) + +# `component_type` / `node_type` per underlying type of a `type_spec`. +_TYPE_MAPPING = { + "struct_type": ("struct", "struct"), + "interface_type": ("interface", "interface"), +} +_DEFINED_TYPE = ("class", "type") + +_FUNCTION_SCOPES = ("function_declaration", "method_declaration") + +# Predeclared types (and `any`/`comparable`) never become edges. +GO_PRIMITIVE_TYPES = frozenset( + { + "bool", + "byte", + "rune", + "string", + "error", + "any", + "comparable", + "int", + "int8", + "int16", + "int32", + "int64", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", + "uintptr", + "float32", + "float64", + "complex64", + "complex128", + } +) + +# Predeclared functions. +GO_BUILTIN_FUNCS = frozenset( + { + "append", + "cap", + "clear", + "close", + "complex", + "copy", + "delete", + "imag", + "len", + "make", + "max", + "min", + "new", + "panic", + "print", + "println", + "real", + "recover", + } +) + +# Standard-library method names. A call whose receiver type is unknown and +# whose only signal is one of these names is dropped instead of being +# matched by name against the repository's methods. +GO_COMMON_METHODS = frozenset( + { + "Error", + "String", + "GoString", + "Format", + "Unwrap", + "Is", + "As", + "Len", + "Cap", + "Less", + "Swap", + "Close", + "Read", + "ReadByte", + "ReadRune", + "ReadString", + "ReadBytes", + "ReadFrom", + "Write", + "WriteByte", + "WriteRune", + "WriteString", + "WriteTo", + "Seek", + "Sync", + "Flush", + "Bytes", + "Reset", + "Grow", + "Truncate", + "Lock", + "Unlock", + "RLock", + "RUnlock", + "TryLock", + "Add", + "Done", + "Wait", + "Load", + "Store", + "CompareAndSwap", + "Err", + "Value", + "Deadline", + "Printf", + "Println", + "Print", + "Sprintf", + "Fprintf", + "Errorf", + "Fatal", + "Fatalf", + "Helper", + "Log", + "Logf", + "Scan", + "Text", + "Next", + "Name", + "Size", + "Stat", + "Kind", + "Type", + "Elem", + "Interface", + "Int", + "Uint", + "Float", + "Bool", + } +) + +_PACKAGE_REF_FILES = "*.go" +_MODULE_RE = re.compile(r"^\s*module\s+\"?([^\s\"]+)\"?", re.MULTILINE) +_REPLACE_RE = re.compile(r"^\s*(?:replace\s+)?\"?([^\s\"(]+)\"?(?:\s+\S+)?\s*=>\s*\"?([^\s\"]+)") +_PACKAGE_CLAUSE_RE = re.compile(r"^\s*package\s+([A-Za-z_]\w*)", re.MULTILINE) +_MAJOR_VERSION_RE = re.compile(r"^v\d+$") + + +# ---------------------------------------------------------------------- +# Package references +# ---------------------------------------------------------------------- + + +def go_package_ref(package_dir: str, name: str) -> str: + """`"sub/*.go::Name"`: declaration `Name` in the package at repo dir `sub`.""" + prefix = f"{package_dir}/" if package_dir else "" + return f"{prefix}{_PACKAGE_REF_FILES}::{name}" + + +def split_go_package_ref(callee: str) -> tuple[str, str] | None: + head, sep, name = callee.partition("::") + if not sep or not name: + return None + if head == _PACKAGE_REF_FILES: + return "", name + suffix = f"/{_PACKAGE_REF_FILES}" + if head.endswith(suffix): + return head[: -len(suffix)], name + return None + + +def is_skipped_go_file(relative_path: str) -> bool: + """`_test.go` files and vendored dependencies yield no components.""" + posix = Path(relative_path).as_posix() + return posix.endswith("_test.go") or "vendor" in posix.split("/")[:-1] + + +def package_dir_of(relative_path: str) -> str: + return posixpath.dirname(Path(relative_path).as_posix()) + + +# ---------------------------------------------------------------------- +# go.mod +# ---------------------------------------------------------------------- + + +@lru_cache(maxsize=256) +def _parse_go_mod(path: str, mtime_ns: int) -> tuple[str | None, tuple[tuple[str, str], ...]]: + """Module path and local `replace` targets of a go.mod file.""" + del mtime_ns # cache key only + try: + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + except OSError: + return None, () + match = _MODULE_RE.search(text) + module = match.group(1) if match else None + replaces = [] + in_block = False + for raw in text.splitlines(): + line = raw.split("//", 1)[0].strip() + if line.startswith("replace") and line.rstrip().endswith("("): + in_block = True + continue + if in_block and line == ")": + in_block = False + continue + if not (in_block or line.startswith("replace")): + continue + m = _REPLACE_RE.match(line) + if m and m.group(2).startswith(("./", "../")): + replaces.append((m.group(1), m.group(2))) + return module, tuple(replaces) + + +def _read_go_mod(path: str): + try: + mtime = os.stat(path).st_mtime_ns + except OSError: + return None, () + return _parse_go_mod(path, mtime) + + +def _normalize_dir(path: str) -> str | None: + norm = posixpath.normpath(path) + if norm == ".": + return "" + if norm.startswith(".."): + return None + return norm + + +@lru_cache(maxsize=1024) +def _package_name_in_dir(abs_dir: str) -> str | None: + """The package clause of the first non-test Go file in a directory.""" + try: + names = sorted(os.listdir(abs_dir)) + except OSError: + return None + for name in names: + if not name.endswith(".go") or name.endswith("_test.go"): + continue + try: + with open(os.path.join(abs_dir, name), encoding="utf-8", errors="replace") as f: + head = f.read(8192) + except OSError: + continue + match = _PACKAGE_CLAUSE_RE.search(re.sub(r"//[^\n]*", "", head)) + if match: + return match.group(1) + return None + + +def _default_import_names(import_path: str) -> set[str]: + """Likely package names of an import path without an explicit alias.""" + parts = [p for p in import_path.split("/") if p] + if not parts: + return set() + last = parts[-1] + if _MAJOR_VERSION_RE.match(last) and len(parts) > 1: + last = parts[-2] + last = re.sub(r"\.v\d+$", "", last) + names = {last} + for prefix in ("go-", "go."): + if last.startswith(prefix): + names.add(last[len(prefix) :]) + for suffix in ("-go", ".go"): + if last.endswith(suffix): + names.add(last[: -len(suffix)]) + names |= {n.replace("-", "") for n in names} | {n.replace("-", "_") for n in names} + return {n for n in names if n.isidentifier()} + + +# ---------------------------------------------------------------------- +# Analyzer +# ---------------------------------------------------------------------- + + +@lru_cache(maxsize=1) +def _go_parser(): + return get_parser("go") + + +class TreeSitterGoAnalyzer: + def __init__(self, file_path: str, content: str, repo_path: str | None = None): + self.file_path = Path(file_path) + self.content = content + self.repo_path = repo_path or "" + self.nodes: list[Node] = [] + self.call_relationships: list[CallRelationship] = [] + # Same-file symbol table keyed by logical name ("Foo", "Foo.Bar"). + self.top_level_nodes: dict = {} + self.seen_relationships: set = set() + self.relative_path = Path(self._get_relative_path()).as_posix() + self.package_dir = package_dir_of(self.relative_path) + # Import name -> repository dir of the package, or None if external. + self.imports: dict[str, str | None] = {} + # The caller's own package plus the repository packages it imports. + self.visible_dirs: set[str] = {self.package_dir} + self._struct_fields: dict[str, dict] = {} + self._func_results: dict[str, object] = {} + self._package_vars: dict[str, tuple[str | None, str] | None] = {} + self._bindings_cache: dict = {} + if is_skipped_go_file(self.relative_path): + return + self._analyze() + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def _get_relative_path(self) -> str: + if self.repo_path: + try: + return os.path.relpath(str(self.file_path), self.repo_path) + except ValueError: + return str(self.file_path) + return str(self.file_path) + + def _get_component_id(self, logical_name: str) -> str: + return f"{self._get_relative_path()}::{logical_name}" + + def _analyze(self): + try: + tree = _go_parser().parse(bytes(self.content, "utf8")) + root = tree.root_node + lines = self.content.splitlines() + + self._collect_imports(root) + self._extract_nodes(root, lines) + self._collect_package_vars(root) + self._extract_relationships(root) + except Exception as e: # noqa: BLE001 — a broken file must not abort the sweep + logger.error(f"Error parsing Go file {self.file_path}: {e}") + + @staticmethod + def _text(node) -> str: + return node.text.decode("utf8", errors="replace") if node is not None else "" + + def _field_text(self, node, field_name: str) -> str | None: + child = node.child_by_field_name(field_name) + return self._text(child) if child is not None else None + + # ------------------------------------------------------------------ + # Imports + # ------------------------------------------------------------------ + + def _collect_imports(self, root): + for child in root.children: + if child.type == "import_declaration": + for spec in self._descendants(child, "import_spec"): + self._register_import(spec) + + def _register_import(self, spec): + path_node = spec.child_by_field_name("path") + if path_node is None: + return + import_path = self._text(path_node).strip('"`') + alias_node = spec.child_by_field_name("name") + alias = self._text(alias_node) if alias_node is not None else None + if alias in ("_", "."): + return + repo_dir = self._resolve_import_dir(import_path) + if repo_dir is not None: + self.visible_dirs.add(repo_dir) + if alias: + names = {alias} + else: + names = _default_import_names(import_path) + if repo_dir is not None and self.repo_path: + actual = _package_name_in_dir(os.path.join(self.repo_path, repo_dir)) + if actual: + names = {actual} + for name in names: + # A repository package wins over an external one of the same name. + if self.imports.get(name) is None: + self.imports[name] = repo_dir + + def _resolve_import_dir(self, import_path: str) -> str | None: + """Repository dir of an imported package, or None when external.""" + if not self.repo_path: + return None + directory = self.package_dir + while True: + go_mod = os.path.join(self.repo_path, directory, "go.mod") + if os.path.isfile(go_mod): + return self._import_dir_from_go_mod(import_path, directory, go_mod) + if not directory: + break + directory = posixpath.dirname(directory) + return self._import_dir_without_go_mod(import_path) + + @staticmethod + def _import_dir_from_go_mod(import_path: str, mod_dir: str, go_mod: str) -> str | None: + module, replaces = _read_go_mod(go_mod) + targets = [(module, "")] if module else [] + targets += [(old, new) for old, new in replaces] + for prefix, local in targets: + if import_path == prefix or import_path.startswith(prefix + "/"): + rest = import_path[len(prefix) :].lstrip("/") + return _normalize_dir(posixpath.join(mod_dir, local, rest)) + return None + + def _import_dir_without_go_mod(self, import_path: str) -> str | None: + """No go.mod above the file (GOPATH layout or a sub-tree was + analyzed): match the longest import-path suffix that is a repo dir. + Standard-library paths (no dot in the first element) never match.""" + parts = import_path.split("/") + if len(parts) < 2 or "." not in parts[0]: + return None + for start in range(1, len(parts)): + candidate = "/".join(parts[start:]) + if os.path.isdir(os.path.join(self.repo_path, candidate)): + return candidate + return None + + # ------------------------------------------------------------------ + # Pass 1: components + # ------------------------------------------------------------------ + + def _extract_nodes(self, root, lines): + for child in root.children: + if child.type == "function_declaration": + self._add_function(child, lines) + elif child.type == "method_declaration": + self._add_method(child, lines) + elif child.type == "type_declaration": + specs = [c for c in child.named_children if c.type == "type_spec"] + grouped = any(c.type == "(" for c in child.children) + for spec in specs: + self._add_type(spec, child if not grouped else spec, lines) + + def _add_function(self, node, lines): + name = self._field_text(node, "name") + if not name or name == "_": + return + result = node.child_by_field_name("result") + if result is not None: + self._func_results.setdefault(name, result) + self._add_node( + node, + name, + "function", + "function", + lines, + parameters=self._extract_parameters(node), + ) + + def _add_method(self, node, lines): + name = self._field_text(node, "name") + owner = self._receiver_type_name(node) + if not name or not owner or name == "_": + return + logical = f"{owner}.{name}" + result = node.child_by_field_name("result") + if result is not None: + self._func_results.setdefault(logical, result) + self._add_node( + node, + logical, + "method", + "method", + lines, + class_name=owner, + parameters=self._extract_parameters(node), + ) + + def _receiver_type_name(self, method_node) -> str | None: + receiver = method_node.child_by_field_name("receiver") + if receiver is None: + return None + for param in receiver.named_children: + if param.type == "parameter_declaration": + ref = self._primary_type_ref(param.child_by_field_name("type")) + if ref and ref[0] is None: + return ref[1] + return None + + def _add_type(self, spec, outer, lines): + name = self._field_text(spec, "name") + type_node = spec.child_by_field_name("type") + if not name or name == "_" or type_node is None: + return + component_type, node_type = _TYPE_MAPPING.get(type_node.type, _DEFINED_TYPE) + node_obj = self._add_node(outer, name, component_type, node_type, lines) + if node_obj is None: + return + + bases: list[str] = [] + if type_node.type == "struct_type": + fields: dict = {} + for decl in self._descendants(type_node, "field_declaration", stop_at_nested=True): + type_child = decl.child_by_field_name("type") + names = decl.children_by_field_name("name") + if not names: + embedded = self._primary_type_ref(type_child) + if embedded: + qualifier, base = embedded + bases.append(f"{qualifier}.{base}" if qualifier else base) + fields.setdefault(base, type_child) + for field_name in names: + fields[self._text(field_name)] = type_child + self._struct_fields[name] = fields + elif type_node.type == "interface_type": + for elem in type_node.named_children: + if elem.type == "type_elem": + for ref in self._collect_type_refs(elem): + qualifier, base = ref + bases.append(f"{qualifier}.{base}" if qualifier else base) + elif elem.type in ("method_elem", "method_spec"): + method = self._field_text(elem, "name") + if method: + self._add_node( + elem, + f"{name}.{method}", + "method", + "method", + lines, + class_name=name, + parameters=self._extract_parameters(elem), + ) + if bases: + node_obj.base_classes = bases + + def _extract_parameters(self, func_node) -> list[str] | None: + params_node = func_node.child_by_field_name("parameters") + if params_node is None: + return None + params = [ + self._text(child).strip() + for child in params_node.named_children + if child.type in ("parameter_declaration", "variadic_parameter_declaration") + ] + return params or None + + def _extract_docstring(self, node) -> str: + """Contiguous comment lines directly above a declaration.""" + comments = [] + expected_line = node.start_point[0] - 1 + sibling = node.prev_sibling + while ( + sibling is not None + and sibling.type == "comment" + and sibling.end_point[0] == expected_line + ): + comments.append(self._text(sibling).strip()) + expected_line = sibling.start_point[0] - 1 + sibling = sibling.prev_sibling + return "\n".join(reversed(comments)) + + def _add_node( + self, + node, + logical_name: str, + component_type: str, + node_type: str, + lines, + class_name: str | None = None, + parameters: list[str] | None = None, + ) -> Node | None: + if logical_name in self.top_level_nodes: + # A second `func init()` in the same file: keep the first. + return None + component_id = self._get_component_id(logical_name) + relative_path = self._get_relative_path() + + docstring = self._extract_docstring(node) + + start_line_idx = node.start_point[0] + end_line_idx = node.end_point[0] + 1 + code_snippet = ( + "\n".join(lines[start_line_idx:end_line_idx]) if start_line_idx < len(lines) else "" + ) + + node_obj = Node( + id=component_id, + name=logical_name, + component_type=component_type, + file_path=str(self.file_path), + relative_path=relative_path, + source_code=code_snippet, + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + has_docstring=bool(docstring), + docstring=docstring, + parameters=parameters, + node_type=node_type, + class_name=class_name, + display_name=f"{node_type} {logical_name}", + component_id=component_id, + language="go", + ) + self.nodes.append(node_obj) + self.top_level_nodes[logical_name] = node_obj + return node_obj + + def _collect_package_vars(self, root): + """Types of package-level `var`s declared in this file.""" + for child in root.children: + if child.type != "var_declaration": + continue + for spec in self._descendants(child, "var_spec"): + for name, ref in self._var_spec_bindings(spec): + self._package_vars[name] = ref + + # ------------------------------------------------------------------ + # Pass 2: relationships + # ------------------------------------------------------------------ + + def _extract_relationships(self, node): + if node.type == "type_spec" and node.parent is not None: + if node.parent.parent is not None and node.parent.parent.type == "source_file": + self._emit_type_spec_edges(node) + elif node.type == "composite_literal": + self._emit_instantiation_edge(node) + elif node.type == "call_expression": + self._emit_call_edge(node) + elif node.type == "type_conversion_expression": + self._emit_generic_call_edge(node) + elif node.type in ("parameter_declaration", "variadic_parameter_declaration"): + if not self._is_receiver(node): + self._emit_type_use_edges(node, node.child_by_field_name("type")) + elif node.type in ("var_spec", "type_assertion_expression"): + self._emit_type_use_edges(node, node.child_by_field_name("type")) + elif node.type in ("function_declaration", "method_declaration", "func_literal"): + result = node.child_by_field_name("result") + if result is not None and result.type != "parameter_list": + self._emit_type_use_edges(result, result) + + for child in node.children: + self._extract_relationships(child) + + @staticmethod + def _descendants(node, node_type: str, stop_at_nested: bool = False): + """Descendants of a given type; with `stop_at_nested`, do not look + inside a match (fields of an anonymous nested struct stay with it).""" + found = [] + stack = list(reversed(node.children)) + while stack: + n = stack.pop() + if n.type == node_type: + found.append(n) + if stop_at_nested: + continue + stack.extend(reversed(n.children)) + return found + + def _primary_type_ref(self, type_node) -> tuple[str | None, str] | None: + """`(package alias or None, type name)` of a type, stripping + pointers, parentheses, and type arguments. Slices, arrays, and maps + give their element type, so `xs[i].M()` and `range xs` values type + their method calls.""" + while type_node is not None and type_node.type in ( + "pointer_type", + "parenthesized_type", + "generic_type", + "type_elem", + "slice_type", + "array_type", + "map_type", + ): + if type_node.type == "generic_type": + type_node = type_node.child_by_field_name("type") + elif type_node.type in ("slice_type", "array_type"): + type_node = type_node.child_by_field_name("element") + elif type_node.type == "map_type": + type_node = type_node.child_by_field_name("value") + else: + type_node = type_node.named_children[0] if type_node.named_children else None + if type_node is None: + return None + if type_node.type == "type_identifier": + return None, self._text(type_node) + if type_node.type == "qualified_type": + package = self._field_text(type_node, "package") + name = self._field_text(type_node, "name") + return (package, name) if package and name else None + return None + + def _collect_type_refs(self, type_node) -> list[tuple[str | None, str]]: + """Every named type a type expression mentions (`map[string]*Event` + and `[]sub.Item` still yield `Event` and `sub.Item`), minus + predeclared types and type parameters in scope.""" + refs: list[tuple[str | None, str]] = [] + if type_node is None: + return refs + generics = self._type_params_in_scope(type_node) + stack = [type_node] + while stack: + n = stack.pop() + ref = None + if n.type == "qualified_type": + ref = self._primary_type_ref(n) + elif n.type == "type_identifier": + name = self._text(n) + if name not in GO_PRIMITIVE_TYPES and name not in generics: + ref = (None, name) + else: + stack.extend(reversed(n.children)) + if ref and ref not in refs: + refs.append(ref) + return refs + + def _type_params_in_scope(self, node) -> set[str]: + """Type parameter names of the declarations enclosing a node, + including those a generic receiver (`func (b *Buffer[T])`) binds.""" + params: set[str] = set() + current = node + while current is not None: + type_params = current.child_by_field_name("type_parameters") + if type_params is not None: + for decl in type_params.named_children: + for name in decl.children_by_field_name("name"): + params.add(self._text(name)) + if current.type == "method_declaration": + receiver = current.child_by_field_name("receiver") + for generic in self._descendants(receiver, "generic_type") if receiver else []: + args = generic.child_by_field_name("type_arguments") + for ident in self._descendants(args, "type_identifier") if args else []: + params.add(self._text(ident)) + current = current.parent + return params + + def _type_ref_target(self, ref) -> tuple[str, bool] | None: + """`(callee, resolved)` for a type reference, or None when it points + outside the repository.""" + qualifier, name = ref + if qualifier is None: + node_obj = self.top_level_nodes.get(name) + if node_obj is not None: + return node_obj.id, True + if name in GO_PRIMITIVE_TYPES: + return None + return go_package_ref(self.package_dir, name), False + repo_dir = self.imports.get(qualifier) + if repo_dir is None: + return None + return go_package_ref(repo_dir, name), False + + def _add_type_ref_relationship(self, caller_id: str, ref, line: int): + target = self._type_ref_target(ref) + if target: + self._add_relationship_raw(caller_id, target[0], line, target[1]) + + def _emit_type_spec_edges(self, spec): + name = self._field_text(spec, "name") + type_node = spec.child_by_field_name("type") + owner = self.top_level_nodes.get(name) if name else None + if owner is None or type_node is None: + return + if type_node.type == "struct_type": + for decl in self._descendants(type_node, "field_declaration"): + for ref in self._collect_type_refs(decl.child_by_field_name("type")): + self._add_type_ref_relationship(owner.id, ref, decl.start_point[0] + 1) + elif type_node.type == "interface_type": + for elem in type_node.named_children: + if elem.type == "type_elem": + for ref in self._collect_type_refs(elem): + self._add_type_ref_relationship(owner.id, ref, elem.start_point[0] + 1) + else: + for ref in self._collect_type_refs(type_node): + self._add_type_ref_relationship(owner.id, ref, spec.start_point[0] + 1) + + # -- callers -------------------------------------------------------- + + def _find_containing_function(self, node): + current = node.parent + while current is not None: + if current.type in _FUNCTION_SCOPES: + return current + current = current.parent + return None + + def _function_logical_name(self, func_node) -> str | None: + name = self._field_text(func_node, "name") + if not name: + return None + if func_node.type == "method_declaration": + owner = self._receiver_type_name(func_node) + return f"{owner}.{name}" if owner else None + return name + + def _resolve_caller(self, node) -> str | None: + func_node = self._find_containing_function(node) + if func_node is None: + return None + logical = self._function_logical_name(func_node) + node_obj = self.top_level_nodes.get(logical) if logical else None + return node_obj.id if node_obj is not None else None + + # -- type uses ------------------------------------------------------ + + @staticmethod + def _is_receiver(param) -> bool: + params = param.parent + owner = params.parent if params is not None else None + if owner is None or owner.type != "method_declaration": + return False + receiver = owner.child_by_field_name("receiver") + return receiver is not None and receiver.start_byte == params.start_byte + + def _emit_type_use_edges(self, anchor, type_node): + """Types a function names in its signature, closures, local `var`s, + and type assertions: it cannot be ported before them.""" + caller_id = self._resolve_caller(anchor) + if not caller_id or type_node is None: + return + for ref in self._collect_type_refs(type_node): + self._add_type_ref_relationship(caller_id, ref, anchor.start_point[0] + 1) + + # -- instantiation -------------------------------------------------- + + def _emit_instantiation_edge(self, literal): + caller_id = self._resolve_caller(literal) + if not caller_id: + return + type_node = literal.child_by_field_name("type") + ref = self._primary_type_ref(type_node) + # `[]Event{...}` / `map[string]Event{...}` instantiate their element types. + refs = [ref] if ref else self._collect_type_refs(type_node) + generics = self._type_params_in_scope(literal) + for ref in refs: + if ref[1] not in generics: + self._add_type_ref_relationship(caller_id, ref, literal.start_point[0] + 1) + + # -- calls ---------------------------------------------------------- + + def _emit_call_edge(self, call_node): + caller_id = self._resolve_caller(call_node) + if not caller_id: + return + func_node = call_node.child_by_field_name("function") + if func_node is None: + return + line = call_node.start_point[0] + 1 + if func_node.type == "identifier": + self._emit_bare_call_edge(caller_id, self._text(func_node), call_node, line) + elif func_node.type == "selector_expression": + self._emit_selector_call_edge(caller_id, func_node, call_node, line) + elif func_node.type == "parenthesized_expression": + # Conversion to a pointer type: `(*stringHeader)(unsafe.Pointer(&s))`. + inner = func_node.named_children[0] if func_node.named_children else None + if inner is not None and inner.type == "unary_expression": + inner = inner.child_by_field_name("operand") + if inner is not None and inner.type == "identifier": + target = self.top_level_nodes.get(self._text(inner)) + if target is not None and target.component_type != "function": + self._add_relationship_raw(caller_id, target.id, line, True) + elif func_node.type == "index_expression": + # Explicitly instantiated generic function: `Map[int](xs)`. + operand = func_node.child_by_field_name("operand") + if operand is not None and operand.type == "identifier": + self._emit_bare_call_edge(caller_id, self._text(operand), call_node, line) + + def _emit_generic_call_edge(self, node): + """`f[int](x)` parses as a conversion to a generic type.""" + caller_id = self._resolve_caller(node) + type_node = node.child_by_field_name("type") + if not caller_id or type_node is None: + return + ref = self._primary_type_ref(type_node) + if not ref: + return + line = node.start_point[0] + 1 + if ref[0] is None: + self._emit_bare_call_edge(caller_id, ref[1], node, line) + else: + self._add_type_ref_relationship(caller_id, ref, line) + + def _emit_bare_call_edge(self, caller_id: str, name: str, call_node, line: int): + found, _ = self._local_binding(call_node, name) + if found: + return # a function value held in a local or parameter + node_obj = self.top_level_nodes.get(name) + if node_obj is not None: + self._add_relationship_raw(caller_id, node_obj.id, line, True) + return + if name in GO_BUILTIN_FUNCS: + if name in ("new", "make"): + args = call_node.child_by_field_name("arguments") + first = args.named_children[0] if args is not None and args.named_children else None + for ref in self._collect_type_refs(first): + self._add_type_ref_relationship(caller_id, ref, line) + return + if name in GO_PRIMITIVE_TYPES or name in self._type_params_in_scope(call_node): + return + if name in self._package_vars: + return # a package-level function variable + self._add_relationship_raw(caller_id, go_package_ref(self.package_dir, name), line, False) + + def _emit_selector_call_edge(self, caller_id: str, func_node, call_node, line: int): + operand = func_node.child_by_field_name("operand") + field = func_node.child_by_field_name("field") + if operand is None or field is None: + return + method = self._text(field) + + if operand.type == "identifier": + ident = self._text(operand) + found, ref = self._local_binding(call_node, ident) + if found: + self._emit_typed_method_edge(caller_id, ref, method, line) + return + if ident in self.imports: + repo_dir = self.imports[ident] + if repo_dir is not None: + self._add_relationship_raw( + caller_id, go_package_ref(repo_dir, method), line, False + ) + return + if ident in self._package_vars: + self._emit_typed_method_edge(caller_id, self._package_vars[ident], method, line) + return + if ident in self.top_level_nodes or ident[:1].isupper(): + # Method expression `Buffer.Push(b)`. + self._emit_typed_method_edge(caller_id, (None, ident), method, line) + return + self._emit_typed_method_edge(caller_id, None, method, line) + return + + self._emit_typed_method_edge(caller_id, self._expression_type(operand), method, line) + + def _emit_typed_method_edge(self, caller_id: str, ref, method: str, line: int): + if ref is None: + if method not in GO_COMMON_METHODS: + self._add_relationship_raw(caller_id, method, line, False) + return + qualifier, type_name = ref + logical = f"{type_name}.{method}" + if qualifier is None: + node_obj = self.top_level_nodes.get(logical) + if node_obj is not None: + self._add_relationship_raw(caller_id, node_obj.id, line, True) + return + if type_name in GO_PRIMITIVE_TYPES: + return + self._add_relationship_raw( + caller_id, go_package_ref(self.package_dir, logical), line, False + ) + return + repo_dir = self.imports.get(qualifier) + if repo_dir is not None: + self._add_relationship_raw(caller_id, go_package_ref(repo_dir, logical), line, False) + + # -- local types ---------------------------------------------------- + + def _expression_type(self, expr) -> tuple[str | None, str] | None: + """Best-effort type of an expression used as a method receiver.""" + if expr is None: + return None + if expr.type == "parenthesized_expression": + return self._expression_type(expr.named_children[0] if expr.named_children else None) + if expr.type in ("unary_expression", "index_expression", "slice_expression"): + return self._expression_type(expr.child_by_field_name("operand")) + if expr.type == "identifier": + found, ref = self._local_binding(expr, self._text(expr)) + if found: + return ref + return self._package_vars.get(self._text(expr)) + if expr.type in ("composite_literal", "type_assertion_expression"): + return self._primary_type_ref(expr.child_by_field_name("type")) + if expr.type == "selector_expression": + return self._field_type(expr) + if expr.type == "call_expression": + return self._call_result_type(expr) + return None + + def _field_type(self, selector) -> tuple[str | None, str] | None: + """Type of `x.field` when `x`'s struct is declared in this file.""" + owner = self._expression_type(selector.child_by_field_name("operand")) + field = selector.child_by_field_name("field") + if owner is None or owner[0] is not None or field is None: + return None + type_node = self._struct_fields.get(owner[1], {}).get(self._text(field)) + return self._primary_type_ref(type_node) + + def _call_result_type(self, call, index: int = 0) -> tuple[str | None, str] | None: + """Type of the `index`-th result of a call to a same-file function + or method (or of `new(T)` / a conversion `Kind(x)`).""" + func = call.child_by_field_name("function") + if func is None: + return None + if func.type == "identifier": + name = self._text(func) + if name == "new": + args = call.child_by_field_name("arguments") + first = args.named_children[0] if args is not None and args.named_children else None + return self._primary_type_ref(first) + if name in self.top_level_nodes and self.top_level_nodes[name].node_type != "function": + return None, name # conversion `Kind(x)` + return self._result_ref(self._func_results.get(name), index) + if func.type == "selector_expression": + receiver = self._expression_type(func.child_by_field_name("operand")) + field = func.child_by_field_name("field") + if receiver and receiver[0] is None and field is not None: + return self._result_ref( + self._func_results.get(f"{receiver[1]}.{self._text(field)}"), index + ) + return None + + def _result_ref(self, result, index: int = 0) -> tuple[str | None, str] | None: + if result is None: + return None + if result.type != "parameter_list": + return self._primary_type_ref(result) if index == 0 else None + # `(int, Result, bool)` or `(n, m int, r Result)`: one slot per name. + slots = [] + for decl in result.named_children: + if decl.type == "parameter_declaration": + type_node = decl.child_by_field_name("type") + slots += [type_node] * max(1, len(decl.children_by_field_name("name"))) + return self._primary_type_ref(slots[index]) if index < len(slots) else None + + def _var_spec_bindings(self, spec) -> list[tuple[str, tuple[str | None, str] | None]]: + names = [self._text(n) for n in spec.children_by_field_name("name")] + declared = self._primary_type_ref(spec.child_by_field_name("type")) + if declared or spec.child_by_field_name("type") is not None: + return [(n, declared) for n in names] + value = spec.child_by_field_name("value") + values = value.named_children if value is not None else [] + return [ + (n, self._expression_type(values[i]) if len(values) == len(names) else None) + for i, n in enumerate(names) + ] + + def _function_bindings(self, func_node) -> dict[str, list[tuple[int, object]]]: + """Every name a function binds (receiver, parameters, `var`, + `:=`, range and type-switch variables, closure parameters), with the + byte offset where its scope starts (the end of the declaring + statement, so `res := res.Get(k)` still sees the outer `res`) and its + inferred type (or None).""" + key = (func_node.start_byte, func_node.end_byte) + cached = self._bindings_cache.get(key) + if cached is not None: + return cached + bindings: dict[str, list[tuple[int, object]]] = defaultdict(list) + self._bindings_cache[key] = bindings + + # Document order, so a binding's initializer can see earlier ones. + stack = [func_node] + while stack: + n = stack.pop() + stack.extend(reversed(n.children)) + if n.type in ("parameter_declaration", "variadic_parameter_declaration"): + ref = self._primary_type_ref(n.child_by_field_name("type")) + for name in n.children_by_field_name("name"): + bindings[self._text(name)].append((n.end_byte, ref)) + elif n.type in ("var_spec", "const_spec"): + for name, ref in self._var_spec_bindings(n): + bindings[name].append((n.end_byte, ref)) + elif n.type == "short_var_declaration": + left = n.child_by_field_name("left") + right = n.child_by_field_name("right") + names = [c for c in left.named_children] if left is not None else [] + values = right.named_children if right is not None else [] + for i, name in enumerate(names): + ref = None + if len(values) == len(names): + ref = self._expression_type(values[i]) + elif len(values) == 1 and values[0].type == "call_expression": + ref = self._call_result_type(values[0], i) + bindings[self._text(name)].append((n.end_byte, ref)) + elif n.type == "range_clause": + left = n.child_by_field_name("left") + names = left.named_children if left is not None else [] + for i, name in enumerate(names): + # `for _, v := range xs`: v is an element of xs. + ref = None + if i == 1: + ref = self._expression_type(n.child_by_field_name("right")) + bindings[self._text(name)].append((n.end_byte, ref)) + elif n.type == "type_switch_statement": + alias = n.child_by_field_name("alias") + for name in alias.named_children if alias is not None else []: + bindings[self._text(name)].append((alias.end_byte, None)) + return bindings + + def _local_binding(self, use_node, name: str) -> tuple[bool, tuple[str | None, str] | None]: + """Whether `name` is a local of the enclosing function at `use_node`, + and its inferred type: the closest binding that precedes the use.""" + func_node = self._find_containing_function(use_node) + if func_node is None: + return False, None + best = None + for start, ref in self._function_bindings(func_node).get(name, []): + if start <= use_node.start_byte and (best is None or start > best[0]): + best = (start, ref) + if best is None: + return False, None + return True, best[1] + + # ------------------------------------------------------------------ + # Bookkeeping + # ------------------------------------------------------------------ + + def _add_relationship_raw( + self, caller: str, callee: str, call_line: int | None, resolved: bool + ): + key = (caller, callee, call_line) + if caller == callee or key in self.seen_relationships: + return + self.seen_relationships.add(key) + self.call_relationships.append( + CallRelationship( + caller=caller, callee=callee, call_line=call_line, is_resolved=resolved + ) + ) + + +def analyze_go_file( + file_path: str, content: str, repo_path: str | None = None +) -> tuple[list[Node], list[CallRelationship]]: + analyzer = TreeSitterGoAnalyzer(file_path, content, repo_path) + return analyzer.nodes, analyzer.call_relationships + + +# ---------------------------------------------------------------------- +# Repository-wide resolution (used by CallGraphAnalyzer) +# ---------------------------------------------------------------------- + + +class GoPackageIndex: + """Go components grouped by package directory.""" + + def __init__(self, functions: dict[str, Node]): + self.functions = functions + self.by_name: dict[tuple[str, str], list[str]] = defaultdict(list) + self.methods: dict[tuple[str, str], list[str]] = defaultdict(list) + for func_id, node in functions.items(): + if node.language != "go": + continue + directory = package_dir_of(node.relative_path) + self.by_name[(directory, node.name)].append(func_id) + if node.component_type == "method" and "." in node.name: + self.methods[(directory, node.name.rsplit(".", 1)[1])].append(func_id) + for ids in (*self.by_name.values(), *self.methods.values()): + ids.sort() + + def lookup(self, directory: str, name: str, depth: int = 0) -> str | None: + """A declaration of the package. Several hits can only come from + build-constrained files (`x_linux.go`, `x_windows.go`): take the + first. `Type.Method` falls back to methods promoted from embedded + types of the same package.""" + ids = self.by_name.get((directory, name)) + if ids: + return ids[0] + if "." not in name or depth >= 3: + return None + owner, method = name.split(".", 1) + for owner_id in self.by_name.get((directory, owner), []): + for base in self.functions[owner_id].base_classes or []: + if "." in base: + continue + found = self.lookup(directory, f"{base}.{method}", depth + 1) + if found: + return found + return None + + +def resolve_go_callee( + callee: str, caller: Node, index: GoPackageIndex, visible_dirs: set[str] | None +) -> str | None: + """Resolve a Go analyzer callee: a package reference names its package; + a bare method name (receiver type unknown) resolves when exactly one + method of that name exists in the packages the caller can see.""" + ref = split_go_package_ref(callee) + if ref is not None: + return index.lookup(*ref) + if "::" in callee or "." in callee: + return None + dirs = visible_dirs or {package_dir_of(caller.relative_path)} + candidates = sorted({fid for d in dirs for fid in index.methods.get((d, callee), [])}) + return candidates[0] if len(candidates) == 1 else None + + +def link_go_interfaces(functions: dict[str, Node]) -> list[CallRelationship]: + """Link each named type to the interfaces of its own package whose + methods it declares (Go implements interfaces implicitly). The + interface is also recorded as a base class of the type.""" + types: dict[str, dict[str, Node]] = defaultdict(dict) + method_sets: dict[tuple[str, str], set[str]] = defaultdict(set) + for node in functions.values(): + if node.language != "go": + continue + directory = package_dir_of(node.relative_path) + if node.component_type == "method" and node.class_name: + method_sets[(directory, node.class_name)].add(node.name.rsplit(".", 1)[-1]) + elif node.node_type in ("struct", "interface", "type"): + types[directory][node.name] = node + + relationships: list[CallRelationship] = [] + for directory, by_name in types.items(): + interfaces = [ + (iface, method_sets.get((directory, iface.name))) + for iface in by_name.values() + if iface.node_type == "interface" + ] + interfaces = [(iface, methods) for iface, methods in interfaces if methods] + if not interfaces: + continue + for type_node in by_name.values(): + if type_node.node_type == "interface": + continue + own = method_sets.get((directory, type_node.name)) + if not own: + continue + for iface, methods in interfaces: + if methods <= own: + bases = type_node.base_classes or [] + if iface.name not in bases: + type_node.base_classes = [*bases, iface.name] + relationships.append( + CallRelationship( + caller=type_node.id, + callee=iface.id, + call_line=type_node.start_line, + is_resolved=True, + ) + ) + return relationships diff --git a/codewiki/src/be/dependency_analyzer/ast_parser.py b/codewiki/src/be/dependency_analyzer/ast_parser.py index e3f67a2d..6d8df152 100644 --- a/codewiki/src/be/dependency_analyzer/ast_parser.py +++ b/codewiki/src/be/dependency_analyzer/ast_parser.py @@ -191,6 +191,7 @@ def _file_to_module_path(self, file_path: str) -> str: ".sc", ".rs", ".dart", + ".go", ] for ext in extensions: if path.endswith(ext): diff --git a/codewiki/src/be/dependency_analyzer/utils/patterns.py b/codewiki/src/be/dependency_analyzer/utils/patterns.py index ce48c541..cf4b0dcc 100644 --- a/codewiki/src/be/dependency_analyzer/utils/patterns.py +++ b/codewiki/src/be/dependency_analyzer/utils/patterns.py @@ -516,7 +516,12 @@ "python": ["def {name}"], "javascript": ["function {name}", "const {name}", "export {name}"], "typescript": ["function {name}", "const {name}", "export {name}"], - "go": ["func {name}"], + "go": [ + "func {name}", + "type {name} struct", + "type {name} interface", + "type {name}", + ], "rust": [ "fn {name}", "pub fn {name}", diff --git a/codewiki/src/be/prompt_template.py b/codewiki/src/be/prompt_template.py index 662ec3e2..0a715bc1 100644 --- a/codewiki/src/be/prompt_template.py +++ b/codewiki/src/be/prompt_template.py @@ -386,6 +386,7 @@ ".sc": "scala", ".rs": "rust", ".dart": "dart", + ".go": "go", ".php": "php", ".phtml": "php", ".inc": "php", diff --git a/guides/development.md b/guides/development.md index e20e8d8e..648cb681 100644 --- a/guides/development.md +++ b/guides/development.md @@ -119,7 +119,9 @@ Analyzers are standalone classes, one file per language. Follow `analyzers/scala.py` (the newest one) as a template. 1. **Grammar.** Add the `tree-sitter-` package to `pyproject.toml` - and `requirements.txt`. + and `requirements.txt`, or take the grammar from + `tree-sitter-language-pack` (already a dependency) with + `get_parser("")`, as `analyzers/dart.py` and `analyzers/go.py` do. 2. **Analyzer.** Create `analyzers/.py` with a `TreeSitterAnalyzer` class that extracts components and relationships, plus a module-level `analyze__file(...)` function. @@ -134,7 +136,8 @@ Analyzers are standalone classes, one file per language. Follow 5. **Tests.** Add `tests/test__analyzer.py` with small source snippets that check component and edge extraction. See `tests/test_scala_analyzer.py`, `tests/test_ruby_analyzer.py`, - `tests/test_rust_analyzer.py`, and `tests/test_dart_analyzer.py`. + `tests/test_rust_analyzer.py`, `tests/test_dart_analyzer.py`, and + `tests/test_go_analyzer.py`. 6. **Docs.** Add the language to the README list. ## Tests and lint diff --git a/tests/test_go_analyzer.py b/tests/test_go_analyzer.py new file mode 100644 index 00000000..db8849f8 --- /dev/null +++ b/tests/test_go_analyzer.py @@ -0,0 +1,423 @@ +"""Tests for the tree-sitter based Go analyzer.""" + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_language_pack") + +from codewiki.src.be.dependency_analyzer.analyzers.artifact import ( + ArtifactOptions, + classify_artifact, +) +from codewiki.src.be.dependency_analyzer.analyzers.go import ( + analyze_go_file, + go_package_ref, + split_go_package_ref, +) +from codewiki.src.be.dependency_analyzer.ast_parser import DependencyParser + +SAMPLE = """\ +package buffer + +import ( + "fmt" + "strings" +) + +// Flusher is implemented by buffers that can be flushed. +type Flusher interface { + Flush() error +} + +// Logger writes structured messages. +type Logger struct { + prefix string +} + +func NewLogger() *Logger { + return &Logger{prefix: "buf"} +} + +// Warn logs a warning. +func (l *Logger) Warn(message string) { + fmt.Println(l.prefix, message) +} + +type Event struct { + ID uint64 +} + +// Kind tells events apart. +type Kind int + +func (k Kind) Label() string { return "kind" } + +type ( + // Signal carries an event downstream. + Signal struct { + event *Event + kind Kind + } + Handler func(ev Event) error +) + +// Buffer holds events before flushing them. +type Buffer[T any] struct { + Logger + capacity int + items []T + index map[uint64]*Event + sink Flusher +} + +func NewBuffer[T any](capacity int) *Buffer[T] { + return &Buffer[T]{capacity: capacity, Logger: *NewLogger()} +} + +func (b *Buffer[T]) Push(item T) { + b.validate() + b.Warn("pushed") + local := NewLogger() + local.Warn("local") + var event Event + record(&event) + b.items = append(b.items, item) + var sb strings.Builder + sb.WriteString("x") + k := Kind(1) + k.Label() + b.sink.Flush() + callback := func() { helper() } + callback() +} + +func (b Buffer[T]) validate() bool { + return len(b.items) < b.capacity +} + +func (b *Buffer[T]) Flush() error { + b.items = nil + return nil +} + +func record(ev *Event) {} +""" + + +def _analyze(tmp_path: Path, source: str = SAMPLE, name: str = "buffer.go"): + file_path = tmp_path / name + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(source, encoding="utf-8") + return analyze_go_file(str(file_path), source, repo_path=str(tmp_path)) + + +def _edges(relationships) -> set[tuple[str, str]]: + return {(r.caller, r.callee) for r in relationships} + + +def test_extracts_types_functions_and_methods(tmp_path: Path) -> None: + nodes, _ = _analyze(tmp_path) + by_name = {node.name: node for node in nodes} + + assert by_name["Buffer"].component_type == "struct" + assert by_name["Flusher"].component_type == "interface" + assert by_name["Kind"].component_type == "class" + assert by_name["Kind"].node_type == "type" + assert by_name["Handler"].node_type == "type" + assert by_name["Signal"].component_type == "struct" + + # Pointer and value receivers both key the method by the base type. + assert by_name["Buffer.Push"].component_type == "method" + assert by_name["Buffer.Push"].class_name == "Buffer" + assert by_name["Buffer.validate"].class_name == "Buffer" + assert by_name["Kind.Label"].class_name == "Kind" + # Interface method signatures are owned by the interface. + assert by_name["Flusher.Flush"].component_type == "method" + + assert by_name["NewBuffer"].component_type == "function" + assert by_name["Buffer"].id == "buffer.go::Buffer" + assert by_name["Buffer.Push"].id == "buffer.go::Buffer.Push" + assert all(node.language == "go" for node in nodes) + + +def test_docstrings_and_parameters(tmp_path: Path) -> None: + nodes, _ = _analyze(tmp_path) + by_name = {node.name: node for node in nodes} + + assert by_name["Buffer"].docstring == "// Buffer holds events before flushing them." + # A spec inside a grouped `type (...)` takes the comment above the spec. + assert by_name["Signal"].docstring == "// Signal carries an event downstream." + assert not by_name["Event"].has_docstring + assert by_name["Logger.Warn"].parameters == ["message string"] + + +def test_embedding_and_interfaces_become_base_classes(tmp_path: Path) -> None: + nodes, relationships = _analyze(tmp_path) + buffer = next(n for n in nodes if n.name == "Buffer") + assert buffer.base_classes == ["Logger"] + assert ("buffer.go::Buffer", "buffer.go::Logger") in _edges(relationships) + + +def test_type_edges(tmp_path: Path) -> None: + _, relationships = _analyze(tmp_path) + edges = _edges(relationships) + + # Field types through pointers, maps, and named types. + assert ("buffer.go::Buffer", "buffer.go::Event") in edges + assert ("buffer.go::Buffer", "buffer.go::Flusher") in edges + assert ("buffer.go::Signal", "buffer.go::Event") in edges + assert ("buffer.go::Signal", "buffer.go::Kind") in edges + # The underlying type of a defined type. + assert ("buffer.go::Handler", "buffer.go::Event") in edges + # Type parameters and predeclared types never become edges. + callees = {callee for _, callee in edges} + assert not any(c.endswith(("::T", "::int", "::uint64", "::any")) for c in callees) + + +def test_call_and_literal_edges(tmp_path: Path) -> None: + _, relationships = _analyze(tmp_path) + edges = _edges(relationships) + + # Composite literals, through `&` and type arguments. + assert ("buffer.go::NewLogger", "buffer.go::Logger") in edges + assert ("buffer.go::NewBuffer", "buffer.go::Buffer") in edges + assert ("buffer.go::NewBuffer", "buffer.go::NewLogger") in edges + # Receiver method calls (value and pointer receivers). + assert ("buffer.go::Buffer.Push", "buffer.go::Buffer.validate") in edges + # A local whose type comes from a same-file constructor's result. + assert ("buffer.go::Buffer.Push", "buffer.go::Logger.Warn") in edges + # A conversion `Kind(1)` types the local. + assert ("buffer.go::Buffer.Push", "buffer.go::Kind.Label") in edges + # A field's interface type resolves the method on the interface. + assert ("buffer.go::Buffer.Push", "buffer.go::Flusher.Flush") in edges + assert ("buffer.go::Buffer.Push", "buffer.go::record") in edges + # Types named in signatures and local `var`s (not the receiver). + assert ("buffer.go::record", "buffer.go::Event") in edges + assert ("buffer.go::NewLogger", "buffer.go::Logger") in edges + assert ("buffer.go::Buffer.Push", "buffer.go::Event") in edges + assert ("buffer.go::Buffer.validate", "buffer.go::Buffer") not in edges + + +def test_promoted_method_is_left_to_package_resolution(tmp_path: Path) -> None: + _, relationships = _analyze(tmp_path) + # `b.Warn()` is promoted from the embedded Logger: the file analyzer + # names `Buffer.Warn` in the package, CallGraphAnalyzer follows the + # embedding. + assert ("buffer.go::Buffer.Push", go_package_ref("", "Buffer.Warn")) in _edges(relationships) + + +def test_external_and_builtin_calls_are_dropped(tmp_path: Path) -> None: + _, relationships = _analyze(tmp_path) + callees = {r.callee for r in relationships} + + for noise in ("Println", "WriteString", "append", "len", "fmt", "strings"): + assert noise not in callees + assert not any("Println" in c or "WriteString" in c for c in callees) + # A closure held in a local is not a call to a package function, but + # the closure body's call is attributed to the enclosing function. + assert go_package_ref("", "callback") not in callees + assert go_package_ref("", "helper") in callees + + +def test_package_ref_round_trip() -> None: + assert go_package_ref("", "Get") == "*.go::Get" + assert go_package_ref("sub/pkg", "T.M") == "sub/pkg/*.go::T.M" + assert split_go_package_ref("sub/pkg/*.go::T.M") == ("sub/pkg", "T.M") + assert split_go_package_ref("*.go::Get") == ("", "Get") + assert split_go_package_ref("buffer.go::Get") is None + assert split_go_package_ref("Get") is None + + +def test_test_files_and_vendor_are_skipped(tmp_path: Path) -> None: + source = "package buffer\n\nfunc TestPush(t *testing.T) { record(nil) }\n" + assert _analyze(tmp_path, source, "buffer_test.go") == ([], []) + vendored = "package dep\n\nfunc Helper() {}\n" + assert _analyze(tmp_path, vendored, "vendor/github.com/x/dep/dep.go") == ([], []) + + +def test_empty_and_broken_files_return_no_components(tmp_path: Path) -> None: + assert _analyze(tmp_path, "", "empty.go") == ([], []) + nodes, _ = _analyze(tmp_path, "package x\nfunc broken( {{{ type", "broken.go") + assert isinstance(nodes, list) + + +def test_go_mod_classifies_as_manifest() -> None: + opts = ArtifactOptions() + assert classify_artifact("go.mod", "go.mod", 100, opts) == "manifest" + assert classify_artifact("main.go", "main.go", 100, opts) is None + + +def _write(root: Path, rel: str, text: str) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_dependency_parser_end_to_end(tmp_path: Path) -> None: + _write(tmp_path, "go.mod", "module github.com/acme/shop\n\ngo 1.22\n") + _write( + tmp_path, + "store/store.go", + "package store\n\n" + "type Config struct { Name string }\n\n" + "type Store struct { cfg Config }\n\n" + "func Open(cfg Config) *Store { return &Store{cfg: cfg} }\n\n" + "func (s *Store) Save(key string) error { return nil }\n\n" + "func (s *Store) Load(key string) string { return key }\n", + ) + # Two files of one package: names cross files without imports. + _write( + tmp_path, + "cart/cart.go", + "package cart\n\n" + "import (\n" + '\t"fmt"\n\n' + '\tdb "github.com/acme/shop/store"\n' + ")\n\n" + "type Base struct{}\n\n" + "func (Base) Describe() string { return describe() }\n\n" + "type Cart struct {\n" + "\tBase\n" + "\tstore *db.Store\n" + "\tlines []Line\n" + "}\n\n" + "type Saver interface { Save(key string) error }\n\n" + "func New() *Cart {\n" + '\ts := db.Open(db.Config{Name: "carts"})\n' + "\treturn &Cart{store: s}\n" + "}\n\n" + "func (c *Cart) Save(key string) error {\n" + "\tc.store.Save(key)\n" + "\tfmt.Println(c.Describe())\n" + "\treturn c.store.Load(key)\n" + "}\n", + ) + _write( + tmp_path, + "cart/line.go", + "package cart\n\ntype Line struct{ Qty int }\n\nfunc describe() string { return total() }\n", + ) + _write(tmp_path, "cart/total.go", 'package cart\n\nfunc total() string { return "" }\n') + _write( + tmp_path, + "cart/cart_test.go", + 'package cart\n\nimport "testing"\n\nfunc TestNew(t *testing.T) { New() }\n', + ) + + components = DependencyParser(str(tmp_path)).parse_repository() + + assert "cart/cart.go::Cart" in components + assert "cart/cart.go::Cart.Save" in components + assert "store/store.go::Store.Save" in components + # _test.go files contribute nothing. + assert not any(cid.startswith("cart/cart_test.go") for cid in components) + + cart = components["cart/cart.go::Cart"].depends_on + # Cross-file field type in the same package, and a type of another + # package imported under an alias through go.mod's module path. + assert "cart/line.go::Line" in cart + assert "store/store.go::Store" in cart + # Embedded struct, and the interface of its package that Cart implements. + assert "cart/cart.go::Base" in cart + assert "cart/cart.go::Saver" in cart + assert "Saver" in components["cart/cart.go::Cart"].base_classes + + new = components["cart/cart.go::New"].depends_on + assert "store/store.go::Open" in new + assert "store/store.go::Config" in new + assert "cart/cart.go::Cart" in new + + save = components["cart/cart.go::Cart.Save"].depends_on + # Method calls through a field of another package's type. + assert "store/store.go::Store.Save" in save + assert "store/store.go::Store.Load" in save + # A method promoted from the embedded Base. + assert "cart/cart.go::Base.Describe" in save + # fmt is external: nothing resolves to it. + assert all(dep in components for dep in save) + + # Same-package call to a function in another file. + assert "cart/line.go::describe" in components["cart/cart.go::Base.Describe"].depends_on + assert "cart/total.go::total" in components["cart/line.go::describe"].depends_on + # Store does not have Save via Cart's Saver: interfaces only link + # within their own package. + assert "cart/cart.go::Saver" not in components["store/store.go::Store"].depends_on + + +def test_unknown_receiver_resolves_only_when_unique(tmp_path: Path) -> None: + _write(tmp_path, "go.mod", "module example.com/m\n") + _write( + tmp_path, + "a.go", + "package m\n\n" + "type Parser struct{}\n\n" + "func (p *Parser) ParseAll() {}\n\n" + "type Lexer struct{}\n\n" + "func (l *Lexer) Step() {}\n\n" + "type Walker struct{}\n\n" + "func (w *Walker) Step() {}\n\n" + "func Run(next func() *Parser, x interface{ Step() }) {\n" + "\tnext().ParseAll()\n" + "\tx.Step()\n" + "}\n", + ) + + components = DependencyParser(str(tmp_path)).parse_repository() + run = components["a.go::Run"].depends_on + assert "a.go::Parser.ParseAll" in run + # Two `Step` methods in scope: ambiguous, no guess. + assert not any(dep.endswith(".Step") for dep in run) + + +def test_local_types_follow_scope_results_and_elements(tmp_path: Path) -> None: + source = """\ +package m + +type Result struct{ Raw string } + +func (r Result) Get(key string) Result { return r } + +func (r Result) Exists() bool { return true } + +func parse(s string) (int, Result, bool) { return 0, Result{}, true } + +func Walk(all []Result, byKey map[string]*Result) { +\t_, res, ok := parse("x") +\tif ok { +\t\tres := res.Get("a") +\t\tres.Exists() +\t} +\tfor _, r := range all { +\t\tr.Exists() +\t} +\tbyKey["k"].Get("b") +} +""" + _, relationships = _analyze(tmp_path, source, "walk.go") + edges = _edges(relationships) + assert ("walk.go::Walk", "walk.go::Result.Get") in edges + assert ("walk.go::Walk", "walk.go::Result.Exists") in edges + assert all(r.is_resolved for r in relationships) + + +def test_local_replace_directive_maps_another_module(tmp_path: Path) -> None: + _write( + tmp_path, + "go.mod", + "module example.com/app\n\n" + "require example.com/lib v0.0.0\n\n" + "replace (\n\texample.com/lib v0.0.0 => ./lib // local copy\n)\n", + ) + _write(tmp_path, "lib/go.mod", "module example.com/lib\n") + _write( + tmp_path, "lib/util/util.go", "package util\n\nfunc Trim(s string) string { return s }\n" + ) + _write( + tmp_path, + "main.go", + 'package main\n\nimport "example.com/lib/util"\n\nfunc main() { util.Trim("x") }\n', + ) + + components = DependencyParser(str(tmp_path)).parse_repository() + assert "lib/util/util.go::Trim" in components["main.go::main"].depends_on