Repository navigation
Description
Pre-flight checks
- I have checked the Troubleshooting section in the README.
What happened?
Released 0.9.108 loses owner distinctions for same-named types in two ways:
Expected: keep distinct types and their members separate, and preserve a call whose qualified receiver or explicit import identifies the correct declaration. Adding an unrelated same-named type should not remove that call.
- Same file: declaration collapse. Different owners' same-named types and methods merge into one node per name. Only the first declaration's location survives; calls can point to the wrong declaration.
- Different files: call loss. Adding an unrelated same-named type removes an existing correct call edge, even though both declarations retain distinct IDs and the selected target ID is unchanged.
These are distinct failures; a shared root cause has not been established.
Minimal same-file Java example (both declarations in Types.java):
class Left {
static class Builder { void setEnabled(boolean value) {} }
}
class Right {
static class Builder { void setEnabled(boolean value) {} }
}Expected: two distinct Builder types and two setEnabled methods. Actual: one of each. The complete scripts below also cover cross-file call loss.
Observed incorrect graph (both owners point to the same merged node):
Left --contains--+
+--> Builder (merged) --method (x2)--> setEnabled() (merged)
Right --contains--+
Verified scope on 0.9.108
| Language | Same-file collapse | Cross-file result |
|---|---|---|
| Java | Reproduced | Call lost: qualified nested receiver / explicit import |
| C# | Reproduced | Call lost: nested / namespace-qualified parameter; using control retains call |
| C++ | Reproduced | Call retained in tested qualified/include-aware controls |
| Kotlin | Reproduced | Call lost: imported singleton object; instance forms unresolved at baseline |
| Scala | Reproduced | Unassessable: qualified-receiver baseline unresolved |
| Swift | Reproduced | Call lost: top-level Builder static call versus nested Right.Builder |
| TypeScript | Reproduced under namespaces | Call retained in tested imported-receiver controls |
Retained calls apply only to tested controls. An unresolved baseline cannot demonstrate addition-induced loss. Neither proves a language unaffected.
Steps to reproduce
In an unmodified v0.9.108 checkout, install the locked dependencies and all language grammars:
uv sync --all-extras --frozenSave the two scripts below in the checkout root, then run:
uv run --no-sync python reproduce_same_file.py
uv run --no-sync python reproduce_cross_file.pyThe original synthetic fixtures are parser-validated; both scripts reject failed/skipped extraction.
A. Same-file declaration collapse: all seven languages
Each file contains two different owners' nested types. Renaming only the second nested type to SecondBuilder is the control. Cases use separate processes and fresh source/cache roots.
A: seven-language same-file script - reproduce_same_file.py
import argparse
import contextlib
import importlib
import io
import subprocess
import sys
import tempfile
from pathlib import Path
from graphify.extract import extract
from tree_sitter import Language, Parser
CASES = [
("Java", "java", "tree_sitter_java", "language", """class OWNER {
static class INNER {
void setEnabled(boolean value) {}
}
}
"""),
("C#", "cs", "tree_sitter_c_sharp", "language", """class OWNER {
public class INNER {
public void setEnabled(bool value) {}
}
}
"""),
("C++", "cpp", "tree_sitter_cpp", "language", """struct OWNER {
struct INNER {
void setEnabled(bool value) {}
};
};
"""),
("Kotlin", "kt", "tree_sitter_kotlin", "language", """class OWNER {
class INNER {
fun setEnabled(value: Boolean) {}
}
}
"""),
("Scala", "scala", "tree_sitter_scala", "language", """class OWNER {
class INNER {
def setEnabled(value: Boolean): Unit = {}
}
}
"""),
("Swift", "swift", "tree_sitter_swift", "language", """class OWNER {
class INNER {
func setEnabled(_ value: Bool) {}
}
}
"""),
("TypeScript", "ts", "tree_sitter_typescript", "language_typescript", """namespace OWNER {
export class INNER {
setEnabled(value: boolean) {}
}
}
"""),
]
def main():
arguments = argparse.ArgumentParser()
arguments.add_argument("--case", type=int, choices=range(2 * len(CASES)))
case = arguments.parse_args().case
if case is None:
print("| Language | Second nested type | Types (actual / expected) | Methods (actual / expected) |")
print("|---|---|---:|---:|", flush=True)
for index in range(2 * len(CASES)):
subprocess.run([sys.executable, __file__, "--case", str(index)], check=True)
return
label, suffix, module, language_fn, template = CASES[case // 2]
second_name = ("Builder", "SecondBuilder")[case % 2]
grammar = importlib.import_module(module)
parser = Parser(Language(getattr(grammar, language_fn)()))
source = template.replace("OWNER", "Left").replace("INNER", "Builder")
source += template.replace("OWNER", "Right").replace("INNER", second_name)
assert not parser.parse(source.encode()).root_node.has_error, label
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
path = root / f"Identity.{suffix}"
path.write_text(source, encoding="utf-8")
with contextlib.redirect_stdout(io.StringIO()):
result = extract(
[path], root=root, cache_root=root / "cache", parallel=False
)
assert not result.get("failed_sources"), result.get("failed_sources")
assert not result.get("skipped_sources"), result.get("skipped_sources")
types = {
node["id"] for node in result["nodes"]
if node.get("source_file")
and node.get("label") in {"Builder", "SecondBuilder"}
}
methods = {
node["id"] for node in result["nodes"]
if node.get("source_file") and node.get("label") == ".setEnabled()"
}
print(f"| {label} | `{second_name}` | {len(types)} / 2 | {len(methods)} / 2 |")
if __name__ == "__main__":
main()B. Cross-file call loss: Java, C#, Kotlin, and Swift
After extracting the target and caller, the script creates a competitor file and performs independent cold extraction. It asserts distinct method IDs and an unchanged selected target ID.
B: four-language cross-file script - reproduce_cross_file.py
import argparse
import contextlib
import importlib
import io
from pathlib import Path
import subprocess
import sys
import tempfile
from graphify.extract import extract
from tree_sitter import Language, Parser
CASES = [
("Java qualified", "java", "tree_sitter_java",
"class Left { static class Builder {\n void setEnabled(boolean v) {}\n} }\n",
"class Caller { void run(Left.Builder b) { b.setEnabled(true); } }\n",
"class Right { static class Builder {\n void setEnabled(boolean v) {}\n} }\n"),
("C# nested", "cs", "tree_sitter_c_sharp",
"public class Left { public class Builder {\n public void setEnabled(bool v) {}\n} }\n",
"public class Caller { public void run(Left.Builder b) { b.setEnabled(true); } }\n",
"public class Right { public class Builder {\n public void setEnabled(bool v) {}\n} }\n"),
("C# namespace", "cs", "tree_sitter_c_sharp",
"namespace Left { public class Builder {\n public void setEnabled(bool v) {}\n} }\n",
"public class Caller { public void run(Left.Builder b) { b.setEnabled(true); } }\n",
"namespace Right { public class Builder {\n public void setEnabled(bool v) {}\n} }\n"),
("Kotlin imported object", "kt", "tree_sitter_kotlin",
"package left\nobject Builder {\n fun setEnabled(v: Boolean) {}\n}\n",
"import left.Builder\nclass Caller {\n fun run() { Builder.setEnabled(true) }\n}\n",
"package right\nobject Builder {\n fun setEnabled(v: Boolean) {}\n}\n"),
("Swift static", "swift", "tree_sitter_swift",
"class Builder {\n static func setEnabled(_ v: Bool) {}\n}\n",
"class Caller {\n func run() { Builder.setEnabled(true) }\n}\n",
"class Right {\n class Builder {\n static func setEnabled(_ v: Bool) {}\n }\n}\n"),
]
def main():
arguments = argparse.ArgumentParser()
arguments.add_argument("--case", type=int, choices=range(len(CASES)))
case = arguments.parse_args().case
if case is None:
print("| Form | Competitor added | Distinct setter nodes (actual / expected) | Correct calls (actual / expected) |")
print("|---|---|---:|---:|", flush=True)
for index in range(len(CASES)):
subprocess.run([sys.executable, __file__, "--case", str(index)], check=True)
return
label, suffix, module, target, caller, competitor = CASES[case]
grammar = importlib.import_module(module)
parser = Parser(Language(grammar.language()))
assert all(not parser.parse(s.encode()).root_node.has_error
for s in (target, caller, competitor)), label
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
left, use, right = root / f"Left.{suffix}", root / f"Caller.{suffix}", root / f"Right.{suffix}"
left.write_text(target, encoding="utf-8")
use.write_text(caller, encoding="utf-8")
original_target = None
for competing in (False, True):
paths = [left, use]
if competing:
right.write_text(competitor, encoding="utf-8")
paths.append(right)
# Independent cold extraction isolates competition from cache effects.
with contextlib.redirect_stdout(io.StringIO()):
result = extract(paths, root=root, cache_root=root / str(competing), parallel=False)
assert not result["failed_sources"], result["failed_sources"]
assert not result["skipped_sources"], result["skipped_sources"]
nodes = {n["id"]: n for n in result["nodes"]}
setters = {n["id"] for n in nodes.values()
if n.get("source_file") and n["label"] == ".setEnabled()"}
targets = {nid for nid in setters
if Path(nodes[nid]["source_file"]).name == left.name}
assert len(setters) == 1 + competing and len(targets) == 1
if original_target is None:
original_target = targets
assert targets == original_target
calls = [e for e in result["edges"]
if e["relation"] == "calls" and e["target"] in setters
and nodes[e["source"]]["label"].lstrip(".") == "run()"
and Path(nodes[e["source"]].get("source_file", "")).name == use.name]
assert all(e["target"] in targets for e in calls), "wrong target"
added = "yes" if competing else "no"
print(f"| {label} | {added} | {len(setters)} / {1 + competing} | {len(calls)} / 1 |")
if __name__ == "__main__":
main()Error output or graph output
A. Same-file script
Expected: all rows report two types and two methods, whether their simple names match or differ.
Actual stdout (Markdown emitted by the script):
| Language | Second nested type | Types (actual / expected) | Methods (actual / expected) |
|---|---|---|---|
| Java | Builder |
1 / 2 | 1 / 2 |
| Java | SecondBuilder |
2 / 2 | 2 / 2 |
| C# | Builder |
1 / 2 | 1 / 2 |
| C# | SecondBuilder |
2 / 2 | 2 / 2 |
| C++ | Builder |
1 / 2 | 1 / 2 |
| C++ | SecondBuilder |
2 / 2 | 2 / 2 |
| Kotlin | Builder |
1 / 2 | 1 / 2 |
| Kotlin | SecondBuilder |
2 / 2 | 2 / 2 |
| Scala | Builder |
1 / 2 | 1 / 2 |
| Scala | SecondBuilder |
2 / 2 | 2 / 2 |
| Swift | Builder |
1 / 2 | 1 / 2 |
| Swift | SecondBuilder |
2 / 2 | 2 / 2 |
| TypeScript | Builder |
1 / 2 | 1 / 2 |
| TypeScript | SecondBuilder |
2 / 2 | 2 / 2 |
B. Cross-file script
Expected: one correct call before and after the competitor is added. Setter-node count should increase from one to two, with the selected target unchanged.
Actual stdout (Markdown emitted by the script):
| Form | Competitor added | Distinct setter nodes (actual / expected) | Correct calls (actual / expected) |
|---|---|---|---|
| Java qualified | no | 1 / 1 | 1 / 1 |
| Java qualified | yes | 2 / 2 | 0 / 1 |
| C# nested | no | 1 / 1 | 1 / 1 |
| C# nested | yes | 2 / 2 | 0 / 1 |
| C# namespace | no | 1 / 1 | 1 / 1 |
| C# namespace | yes | 2 / 2 | 0 / 1 |
| Kotlin imported object | no | 1 / 1 | 1 / 1 |
| Kotlin imported object | yes | 2 / 2 | 0 / 1 |
| Swift static | no | 1 / 1 | 1 / 1 |
| Swift static | yes | 2 / 2 | 0 / 1 |
Both failures are already visible in extract() output, before graph construction or serialization.
The cross-file script may emit an existing stat-index cache-root warning on stderr; the tables contain stdout only.
Graphify version
Unmodified 0.9.108, tag v0.9.108, commit 52cb28218eea0c68d80a9f4b2ae102d2fa63e267.
Operating System
Windows
Python Version
3.13
Installation Method
built from source (git clone)
Additional Environment Details
- Both embedded scripts checked on CPython 3.13.14. Distribution:
graphifyy; import/CLI:graphify. - AST-only
extract()with explicit source lists andparallel=False; no provider calls. - Tree-sitter acceptance is not native compiler verification; external projects were not compiled.
Additional context
Public-source same-file evidence
Also confirmed in six public production files, including generated Scala and TypeScript declarations.
Six pinned source examples, declaration counts, and verification details
The complete files below were extracted on the same release using CPython 3.14.3. All parsed without grammar errors and had no failed/skipped sources. Counts refer to the named declarations, not every symbol.
| Language | Real source and distinct owner-qualified declarations | Expected -> actual |
|---|---|---|
| C# | dotnet/runtime, PartitionerStatic.cs: InternalPartitionEnumerable under DynamicPartitionerForIEnumerable, DynamicPartitionerForIList, and DynamicPartitionerForArray, each explicitly declaring public GetEnumerator. Handwritten production, MIT. |
3 types / 3 public methods -> 1 / 1 |
| C++ | EASTL map.h: map::value_compare and multimap::value_compare, each declaring operator(). Actual nested classes, not aliases; complete header parsed without preprocessing. Handwritten production, BSD-3-Clause. |
2 types / 2 methods -> 1 / 1 |
| Kotlin | Kaluga HUD.kt: BaseHUD.Builder.create and HUD.Builder.create, an explicitly declared override, not an expect/actual duplicate pair. Deprecated production Kotlin Multiplatform API, Apache-2.0. |
2 types / 2 methods -> 1 / 1 |
| Scala | ScalaPB runtime 0.11.20 sources JAR, member com/google/protobuf/descriptor/FieldDescriptorProto.scala: FieldDescriptorProto.Type.Unrecognized at name L571 and .Label.Unrecognized at name L640. Different enum owners, not a companion pair. Generated production runtime, Apache-2.0. |
2 types -> 1; method collapse not claimed for this real-source example |
| Swift | Swift Algorithms Reductions.swift: ExclusiveReductionsSequence.Iterator and InclusiveReductionsSequence.Iterator, each declaring next. Nested structs in extensions of different owners. Handwritten production, Apache-2.0. |
2 types / 2 methods -> 1 / 1 |
| TypeScript | Google Cloud protos.d.ts: google.cloud.language.v1.Document, v1beta2.Document, and v2.Document, each declaring static encode. Generated production ambient declarations, not runtime method bodies, Apache-2.0. |
3 types / 3 methods -> 1 / 1 |
Scala source-artifact SHA256: eb0928b0abb8f8555703a8ba678155ed3974cb92518ad1aae2d13824e0182830. The retained node is fielddescriptorproto_unrecognized at L570, where the first declaration's annotation starts.
The seven-language same-file script also produced identical results on CPython 3.14.3. Supporting checks covered cold, repeated/warm, incremental-addition, and reversed-input extraction. The selected cross-file target IDs remained unchanged.
For Swift, renaming only the competitor to Other, keeping its method name, preserves the call. The caller selects the top-level Builder, not Right.Builder.
Any fix should preserve legitimate ambiguity safeguards, partial/reopened declarations, and cache/incremental correctness.
Activity
github-actions commentedon Nov 12, 2026
Thanks for opening this issue, @y-src. A maintainer will take a look soon.
If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.
To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.
[-][Bug]: Java: same-named nested types cause missing or incorrect call relationships[/-][+][Bug]: Same-named types under different owners cause merged declarations or missing call relationships[/+]