Skip to content

[Bug]: Same-named types under different owners cause merged declarations or missing call relationships #5185

Open

Description

@y-src

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.

  1. 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.
  2. 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 --frozen

Save 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.py

The 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 and parallel=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

  1. github-actions commented on Nov 12, 2026

    @github-actions

    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.

  2. changed the title [-][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[/+] on Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions