From 9674413be35c4829b080f396d81722444cd658f8 Mon Sep 17 00:00:00 2001 From: anhnh2002 Date: Fri, 30 Oct 2026 13:55:54 +0700 Subject: [PATCH 1/2] fix(analyzers): a class's constructor is a component like any method JavaScript (`constructor`), TypeScript (`constructor`) and Ruby (`initialize`) left constructors out of the graph on purpose; Java, C#, Kotlin (secondary constructors, `init` blocks) and Dart never emitted them. Python (`__init__`), PHP (`__construct`), C++ (`A.A`) and Scala (`A.this`) already did, as methods. A constructor often does the real work (node-semver's SemVer constructor parses the whole version string), so a tool that walks the graph's functions (documentation, or a review of a translation function by function) never saw it. In JS and Ruby it was still registered for call resolution, so calls in its body had a caller that was not a component and ast_parser dropped those edges; in Java, C#, Kotlin and Dart they went to the class. Now every one of these analyzers emits the constructor as a `method` component, named as the others are: `Class.constructor` (JS, TS), `Class.initialize` (Ruby), `Class.Class` (Java, C#, Dart's unnamed constructor; a record's compact constructor in Java), `Class.named` (Dart named and factory constructors), `Class.constructor` and `Class.init` (Kotlin). Calls in its body have it as caller. A Dart constructor with no code of its own (no body, no initializer list: `A(this.x);`, a redirecting factory) stays with its class, as before. Measured on bench repos (components / edges, before -> after): node-semver 104/110 -> 108/119 (4 constructors, 9 edges no longer dropped), superstruct 97/183 -> 99/185, rainbow 103 -> 108, semver4j 82 -> 90, semver (C#) 897 -> 920, qrcode (multi-language) 519/936 -> 540/937; cJSON, lz4, goja and tinyxml2 unchanged. Not covered: overloaded constructors share one id, as overloaded methods already do; Kotlin's and Scala's primary constructors have no body of their own (their code stays with the class). --- .../dependency_analyzer/analyzers/csharp.py | 6 +- .../be/dependency_analyzer/analyzers/dart.py | 33 +++- .../be/dependency_analyzer/analyzers/java.py | 9 +- .../analyzers/javascript.py | 5 +- .../dependency_analyzer/analyzers/kotlin.py | 22 ++- .../be/dependency_analyzer/analyzers/ruby.py | 8 +- .../analyzers/typescript.py | 3 +- tests/test_constructors_are_components.py | 161 ++++++++++++++++++ tests/test_dart_analyzer.py | 7 +- tests/test_ruby_analyzer.py | 4 +- 10 files changed, 229 insertions(+), 29 deletions(-) create mode 100644 tests/test_constructors_are_components.py diff --git a/codewiki/src/be/dependency_analyzer/analyzers/csharp.py b/codewiki/src/be/dependency_analyzer/analyzers/csharp.py index dbde49d6..1ea7bed9 100644 --- a/codewiki/src/be/dependency_analyzer/analyzers/csharp.py +++ b/codewiki/src/be/dependency_analyzer/analyzers/csharp.py @@ -203,7 +203,9 @@ def _extract_nodes(self, node, top_level_nodes, lines): node_type = "delegate" node_name = self._decl_name(node) qualified_name = self._qualify(node, *self._find_containing_type_names(node), node_name) - elif node.type == "method_declaration": + elif node.type in ("method_declaration", "constructor_declaration"): + # A constructor is a component like any method (`A.A`, as C++ names it): it often + # does the real work, and calls in its body need it as their caller. method_name = self._decl_name(node) if method_name: node_type = "method" @@ -626,7 +628,7 @@ def _find_containing_type_names(self, node) -> list: def _find_containing_method(self, node): current = node.parent while current: - if current.type == "method_declaration": + if current.type in ("method_declaration", "constructor_declaration"): method_name = self._decl_name(current) class_name = self._find_containing_class_name(current) if method_name and class_name: diff --git a/codewiki/src/be/dependency_analyzer/analyzers/dart.py b/codewiki/src/be/dependency_analyzer/analyzers/dart.py index 9f5a0b35..c773265d 100644 --- a/codewiki/src/be/dependency_analyzer/analyzers/dart.py +++ b/codewiki/src/be/dependency_analyzer/analyzers/dart.py @@ -22,9 +22,11 @@ the broken span are still extracted. - Generic invocations ``Foo(x)`` parse as relational expressions and are recovered from the node text. -- Constructors, setters and operators are not components; calls in their - bodies, in initializer lists and in field initializers are attributed - to the enclosing class. +- A constructor with a body or an initializer list is a component (``A.A`` + for the unnamed one, ``A.named`` for a named or factory one), like any + method: it often does the real work. Constructors with neither, setters + and operators are not components; calls in their code and in field + initializers are attributed to the enclosing class. """ import logging @@ -57,6 +59,10 @@ # Member signatures that become components, with their node_type. _MEMBER_SIGNATURES = {"function_signature": "method", "getter_signature": "getter"} _TOP_LEVEL_SIGNATURES = {"function_signature": "function", "getter_signature": "getter"} +# Constructors that become components when they have code of their own (a body or initializers). +_CONSTRUCTOR_SIGNATURES = frozenset( + {"constructor_signature", "factory_constructor_signature", "constant_constructor_signature"} +) # Member signatures whose code belongs to the class rather than a component. _CLASS_LEVEL_SIGNATURES = frozenset( { @@ -493,7 +499,26 @@ def _add_member(self, owner: str, wrapper, body, lines): if body is not None: self._scan_targets.append(_ScanTarget(node.id, owner, body, signature, name)) return - # Constructors (incl. initializer lists), setters, operators: the + if signature.type in _CONSTRUCTOR_SIGNATURES and ( + body is not None or _first_child(wrapper, "initializers") is not None + ): + names = [_text(c) for c in signature.named_children if c.type == "identifier"] + name = names[-1] if names else owner # `A(...)` -> A.A, `A.named(...)` -> A.named + node = self._add_node( + wrapper, + body or wrapper, + f"{owner}.{name}", + "method", + "constructor", + lines, + class_name=owner, + parameters=_parameter_names(signature), + ) + self._scan_targets.append(_ScanTarget(node.id, owner, wrapper, signature)) + if body is not None: + self._scan_targets.append(_ScanTarget(node.id, owner, body, signature)) + return + # Constructors with no code of their own, setters, operators: the # class owns their code. class_id = self._component_id(owner) self._scan_targets.append(_ScanTarget(class_id, owner, wrapper, signature)) diff --git a/codewiki/src/be/dependency_analyzer/analyzers/java.py b/codewiki/src/be/dependency_analyzer/analyzers/java.py index f8470f3f..223eea9c 100644 --- a/codewiki/src/be/dependency_analyzer/analyzers/java.py +++ b/codewiki/src/be/dependency_analyzer/analyzers/java.py @@ -14,6 +14,9 @@ logger = logging.getLogger(__name__) +# A record's compact constructor (`record R(int x) { R { ... } }`) has no parameter list. +CONSTRUCTORS = ("constructor_declaration", "compact_constructor_declaration") + class TreeSitterJavaAnalyzer: def __init__(self, file_path: str, content: str, repo_path: str = None): @@ -133,7 +136,9 @@ def _extract_nodes(self, node, top_level_nodes, lines): qualified_name = self._qualified_type_name( node_name, self._find_containing_type_names(node) ) - elif node.type == "method_declaration": + elif node.type in ("method_declaration", *CONSTRUCTORS): + # A constructor is a component like any method (`A.A`, as C++ names it): it often + # does the real work, and calls in its body need it as their caller. node_type = "method" name_node = next((c for c in node.children if c.type == "identifier"), None) if name_node: @@ -602,7 +607,7 @@ def _find_containing_type_names(self, node) -> list[str]: def _find_containing_method(self, node): current = node.parent while current: - if current.type == "method_declaration": + if current.type in ("method_declaration", *CONSTRUCTORS): method_name = self._get_identifier_name(current) class_name = self._find_containing_class_name(current) if method_name and class_name: diff --git a/codewiki/src/be/dependency_analyzer/analyzers/javascript.py b/codewiki/src/be/dependency_analyzer/analyzers/javascript.py index 0e1e5ad7..e979cc41 100644 --- a/codewiki/src/be/dependency_analyzer/analyzers/javascript.py +++ b/codewiki/src/be/dependency_analyzer/analyzers/javascript.py @@ -176,8 +176,9 @@ def _extract_methods_from_class(self, class_node, class_name: str) -> None: if method_name: method_node = self._create_method_node(child, method_name, class_name) if method_node: - if method_name != "constructor": - self.nodes.append(method_node) + # A constructor is a component like any method (Python's __init__ is one): it + # often does the real work, and calls in its body need it as their caller. + self.nodes.append(method_node) self.top_level_nodes[method_node.name] = method_node elif child.type == "field_definition": # Handle arrow function properties diff --git a/codewiki/src/be/dependency_analyzer/analyzers/kotlin.py b/codewiki/src/be/dependency_analyzer/analyzers/kotlin.py index 4160518e..611713ce 100644 --- a/codewiki/src/be/dependency_analyzer/analyzers/kotlin.py +++ b/codewiki/src/be/dependency_analyzer/analyzers/kotlin.py @@ -10,6 +10,11 @@ logger = logging.getLogger(__name__) +# A constructor with a body is a component like any method (it often does the real work, and calls +# in its body need it as their caller). The primary constructor has no body of its own. +CONSTRUCTORS = {"secondary_constructor": "constructor", "anonymous_initializer": "init"} + + class TreeSitterKotlinAnalyzer: def __init__(self, file_path: str, content: str, repo_path: Optional[str] = None): self.file_path = Path(file_path) @@ -97,10 +102,9 @@ def _extract_nodes(self, node, top_level_nodes, lines): name_node = next((c for c in node.children if c.type == "identifier"), None) node_name = name_node.text.decode() if name_node else None - elif node.type == "function_declaration": - name_node = next((c for c in node.children if c.type == "identifier"), None) - if name_node: - method_name = name_node.text.decode() + elif node.type in ("function_declaration", *CONSTRUCTORS): + method_name = self._member_name(node) + if method_name: containing_class = self._find_containing_class_name(node) if containing_class: node_type = "method" @@ -414,12 +418,18 @@ def _find_containing_class(self, node, top_level_nodes): return self._get_component_id(class_name) return None + def _member_name(self, node) -> Optional[str]: + """A function's name; a secondary constructor is `constructor`, an `init` block `init`.""" + if node.type in CONSTRUCTORS: + return CONSTRUCTORS[node.type] + return self._get_identifier_name(node) + def _find_containing_method(self, node): """Find the component ID of the containing function/method.""" current = node.parent while current: - if current.type == "function_declaration": - method_name = self._get_identifier_name(current) + if current.type in ("function_declaration", *CONSTRUCTORS): + method_name = self._member_name(current) class_name = self._find_containing_class_name(current) if method_name: if class_name: diff --git a/codewiki/src/be/dependency_analyzer/analyzers/ruby.py b/codewiki/src/be/dependency_analyzer/analyzers/ruby.py index 0268b447..5ce78726 100644 --- a/codewiki/src/be/dependency_analyzer/analyzers/ruby.py +++ b/codewiki/src/be/dependency_analyzer/analyzers/ruby.py @@ -550,9 +550,6 @@ def _add_method_node(self, node, method_name: str, scope: list[str], lines): class_name = None parameters = self._method_parameters(node) - # Constructors are registered for call resolution but are not - # documentable components themselves. - include_in_nodes = method_name != "initialize" self._add_node( node, logical_name, @@ -560,7 +557,6 @@ def _add_method_node(self, node, method_name: str, scope: list[str], lines): lines, parameters=parameters, class_name=class_name, - include_in_nodes=include_in_nodes, ) def _add_node( @@ -573,7 +569,6 @@ def _add_node( base_classes: list[str] | None = None, class_name: str | None = None, qualified_name: str | None = None, - include_in_nodes: bool = True, ): component_id = self._get_component_id(logical_name) relative_path = self._get_relative_path() @@ -613,8 +608,7 @@ def _add_node( language="ruby", qualified_name=qualified_name or logical_name, ) - if include_in_nodes: - self.nodes.append(node_obj) + self.nodes.append(node_obj) self.top_level_nodes[logical_name] = node_obj def _method_parameters(self, method_node) -> list[str] | None: diff --git a/codewiki/src/be/dependency_analyzer/analyzers/typescript.py b/codewiki/src/be/dependency_analyzer/analyzers/typescript.py index 10e54709..cc600379 100644 --- a/codewiki/src/be/dependency_analyzer/analyzers/typescript.py +++ b/codewiki/src/be/dependency_analyzer/analyzers/typescript.py @@ -608,7 +608,8 @@ def _create_node_from_entity(self, entity_data: dict) -> Optional[Node]: return None def _should_include_node(self, node: Node) -> bool: - excluded_names = {"constructor", "__proto__", "prototype"} + # A constructor is a component like any method: it often does the real work. + excluded_names = {"__proto__", "prototype"} if node.component_type == "variable": return False diff --git a/tests/test_constructors_are_components.py b/tests/test_constructors_are_components.py new file mode 100644 index 00000000..cdefc6de --- /dev/null +++ b/tests/test_constructors_are_components.py @@ -0,0 +1,161 @@ +"""A class's constructor is a component like any method, in every object-oriented analyzer. + +It often does the real work (node-semver's ``SemVer`` constructor parses the whole version string), and calls +in its body need it as their caller: JS and Ruby registered the constructor only for call resolution, so its +calls had a caller that was not a component and their edges were dropped; Java, C#, Kotlin and Dart gave +them to the class. Python (``__init__``), PHP (``__construct``), C++ (``A.A``) and Scala (``A.this``) already +emitted it as a method. +""" + +import pytest + +from codewiki.src.be.dependency_analyzer.analyzers.csharp import analyze_csharp_file +from codewiki.src.be.dependency_analyzer.analyzers.dart import TreeSitterDartAnalyzer +from codewiki.src.be.dependency_analyzer.analyzers.java import analyze_java_file +from codewiki.src.be.dependency_analyzer.analyzers.javascript import ( + analyze_javascript_file_treesitter, +) +from codewiki.src.be.dependency_analyzer.analyzers.kotlin import TreeSitterKotlinAnalyzer +from codewiki.src.be.dependency_analyzer.analyzers.ruby import analyze_ruby_file +from codewiki.src.be.dependency_analyzer.analyzers.typescript import ( + analyze_typescript_file_treesitter, +) + + +def _kotlin(path, src, repo): + a = TreeSitterKotlinAnalyzer(path, src, repo) + return a.nodes, a.call_relationships + + +def _dart(path, src, repo): + a = TreeSitterDartAnalyzer(path, src, repo) + return a.nodes, a.call_relationships + + +CASES = { + "javascript": ( + analyze_javascript_file_treesitter, + "semver.js", + """function parse(v) { return v } +class SemVer { + constructor(version) { this.raw = parse(version) } + format() { return this.raw } +} +""", + "SemVer.constructor", + "parse", + ), + "typescript": ( + analyze_typescript_file_treesitter, + "struct.ts", + """function check(x: number): number { return x } +export class Struct { + value: number + constructor(v: number) { this.value = check(v) } +} +""", + "Struct.constructor", + "check", + ), + "ruby": ( + analyze_ruby_file, + "color.rb", + """def parse(x) x end +class Color + def initialize(x) + @v = parse(x) + end +end +""", + "Color.initialize", + "parse", + ), + "java": ( + analyze_java_file, + "A.java", + """class A { + private int v; + A(int x) { v = parse(x); } + int parse(int x) { return x; } +} +""", + "A.A", + "parse", + ), + "csharp": ( + analyze_csharp_file, + "A.cs", + """class A { + int v; + public A(int x) { v = Parse(x); } + int Parse(int x) { return x; } +} +""", + "A.A", + "Parse", + ), + "kotlin": ( + _kotlin, + "A.kt", + """class A(val x: Int) { + var v = 0 + constructor(s: String) : this(s.length) { v = parse(x) } + fun parse(x: Int): Int = x +} +""", + "A.constructor", + "parse", + ), + "dart": ( + _dart, + "a.dart", + """class A { + int v; + A(int x) : v = 0 { v = parse(x); } + static int parse(x) => 0; +} +""", + "A.A", + "parse", + ), +} + + +@pytest.mark.parametrize("lang", sorted(CASES)) +def test_a_constructor_is_a_method_component_and_the_caller_of_its_calls(lang, tmp_path): + analyze, name, src, ctor, callee = CASES[lang] + path = tmp_path / name + path.write_text(src) + nodes, edges = analyze(str(path), src, str(tmp_path)) + by_name = {n.name: n for n in nodes} + assert ctor in by_name, sorted(by_name) + assert by_name[ctor].component_type in ("method", "function") + assert by_name[ctor].id == f"{name}::{ctor}" + callers = {e.caller for e in edges if e.callee.split(".")[-1].split("::")[-1] == callee} + assert f"{name}::{ctor}" in callers, sorted((e.caller, e.callee) for e in edges) + + +def test_kotlin_init_blocks_are_components(tmp_path): + src = """class A(val x: Int) { + var v = 0 + init { v = parse(x) } + fun parse(x: Int): Int = x +} +""" + nodes, edges = _kotlin(str(tmp_path / "A.kt"), src, str(tmp_path)) + assert "A.init" in {n.name for n in nodes} + assert any(e.caller == "A.kt::A.init" and e.callee.endswith("parse") for e in edges) + + +def test_a_dart_constructor_with_no_code_of_its_own_stays_with_its_class(tmp_path): + src = """class A { + int v; + A(this.v); + const A.c(this.v); + factory A.redir(int x) = A; + factory A.of(int x) => A(x); +} +""" + names = {n.name for n in _dart(str(tmp_path / "a.dart"), src, str(tmp_path))[0]} + assert "A.of" in names + assert not names & {"A.A", "A.c", "A.redir"} diff --git a/tests/test_dart_analyzer.py b/tests/test_dart_analyzer.py index e7f21c58..8f9c024e 100644 --- a/tests/test_dart_analyzer.py +++ b/tests/test_dart_analyzer.py @@ -99,6 +99,7 @@ def test_extracts_declarations_with_types(tmp_path: Path) -> None: "CarRepo.fetch": ("method", "method"), "CarRepo.count": ("method", "getter"), "CarRepo.dispose": ("method", "method"), + "CarRepo.create": ("method", "constructor"), "StringX": ("class", "extension"), "StringX.shout": ("method", "method"), "extension_on_int": ("class", "extension"), @@ -109,8 +110,8 @@ def test_extracts_declarations_with_types(tmp_path: Path) -> None: "appName": ("function", "getter"), } assert {name: (n.component_type, n.node_type) for name, n in by_name.items()} == expected - # Constructors and setters are not components. - assert "CarRepo.create" not in by_name + # A constructor with code of its own is a component; one without (`CarRepo(this.api);`) is not. + assert "CarRepo.CarRepo" not in by_name assert all(node.language == "dart" for node in nodes) assert by_name["CarRepo"].id == "lib/repo.dart::CarRepo" assert by_name["CarRepo.fetch"].class_name == "CarRepo" @@ -206,7 +207,7 @@ def test_call_and_instantiation_edges(tmp_path: Path) -> None: assert ("CarRepo.fetch", "log", False) in edges # inherited, resolved cross-file later assert ("CarRepo.fetch", "Car", False) in edges # Car.fromJson -> type edge assert ("CarRepo", "CarRepo", True) not in edges # no self edges - assert ("CarRepo", "ApiClient", False) in edges # factory body -> class-level + assert ("CarRepo.create", "ApiClient", False) in edges # factory body -> the factory assert ("Status.isIdle", "Status", True) in edges # enum value access callees = {callee for _, callee, _ in edges} assert "print" not in callees diff --git a/tests/test_ruby_analyzer.py b/tests/test_ruby_analyzer.py index 3e6653f1..6870c7cd 100644 --- a/tests/test_ruby_analyzer.py +++ b/tests/test_ruby_analyzer.py @@ -69,8 +69,8 @@ def test_extracts_modules_classes_and_methods(tmp_path: Path) -> None: assert by_name["Pipeline.default_buffer"].component_type == "method" assert by_name["standalone_helper"].component_type == "function" - # Constructors are not documentable components. - assert "Buffer.initialize" not in by_name + # A constructor is a component like any method. + assert by_name["Buffer.initialize"].component_type == "method" assert by_name["Buffer"].id == "buffer.rb::Buffer" assert by_name["Buffer.push"].id == "buffer.rb::Buffer.push" From 729211ec731a9b5029ce17773713360f1df6c5ce Mon Sep 17 00:00:00 2001 From: anhnh2002 Date: Fri, 30 Oct 2026 14:00:21 +0700 Subject: [PATCH 2/2] test: skip the C# constructor case where tree-sitter cannot load the C# grammar requirements.txt pins tree-sitter 0.23.2, whose Parser refuses tree-sitter-c-sharp 0.23.1's ABI 15 grammar ("Incompatible Language version 15. Must be between 13 and 14"), so the C# analyzer cannot run at all under those pins; a fresh install from pyproject (tree-sitter 0.26) runs it. No C# test existed before, so CI never showed it. The case is skipped where the grammar does not load, not hidden where it does. --- tests/test_constructors_are_components.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_constructors_are_components.py b/tests/test_constructors_are_components.py index cdefc6de..32d5ab68 100644 --- a/tests/test_constructors_are_components.py +++ b/tests/test_constructors_are_components.py @@ -121,8 +121,24 @@ def initialize(x) } +def _grammar_loads(lang: str) -> bool: + """requirements.txt pins tree-sitter 0.23.2, which cannot load tree-sitter-c-sharp's ABI 15 grammar.""" + if lang != "csharp": + return True + import tree_sitter_c_sharp + from tree_sitter import Language, Parser + + try: + Parser(Language(tree_sitter_c_sharp.language())) + except ValueError: + return False + return True + + @pytest.mark.parametrize("lang", sorted(CASES)) def test_a_constructor_is_a_method_component_and_the_caller_of_its_calls(lang, tmp_path): + if not _grammar_loads(lang): + pytest.skip(f"this tree-sitter cannot load the {lang} grammar") analyze, name, src, ctor, callee = CASES[lang] path = tmp_path / name path.write_text(src)