From 5c27cba0128cf62c626970ed952c3d8d287b959b Mon Sep 17 00:00:00 2001 From: y-src <20279096+y-src@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:51:34 +0000 Subject: [PATCH] fix(extract): preserve nested type ownership and qualified calls Keep same-named declarations distinct under their enclosing types, and resolve Java, C#, Kotlin and Swift receivers against the selected lexical, imported or inherited owner rather than unrelated namesakes. Preserve portable resolution context in full, cached and incremental CLI/watch extraction. Retire stale extraction facts with cache schema 47 and document the required full graph/source-evidence rebuild. Add seven-language identity coverage, scoped-call precision controls and regression tests for the review findings. Fixes #5185 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 34 ++ graphify/cache.py | 3 +- graphify/cli.py | 10 +- graphify/extract.py | 230 +++++++++-- graphify/extractors/csharp.py | 47 ++- graphify/extractors/engine.py | 201 +++++++--- graphify/watch.py | 7 +- tests/test_csharp_extension_keyword_types.py | 13 +- tests/test_csharp_overloads.py | 2 +- tests/test_java_nested_type_identity.py | 389 +++++++++++++++++++ tests/test_kotlin_java_call_edges_4935.py | 31 +- tests/test_nested_type_owners.py | 73 ++++ tests/test_qualified_type_competition.py | 321 +++++++++++++++ tests/test_ts_namespace.py | 2 +- 14 files changed, 1259 insertions(+), 104 deletions(-) create mode 100644 tests/test_java_nested_type_identity.py create mode 100644 tests/test_nested_type_owners.py create mode 100644 tests/test_qualified_type_competition.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 430eae1733..7bb85772b3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,6 +55,40 @@ Always pass `root`. Node ids and `source_file` values are derived relative to it ## Extraction output schema +The shared extractor includes the enclosing type ID in nested declaration IDs, +so `Left.Builder` and `Right.Builder` in one file remain distinct. Ruby retains +its already-qualified class labels; TypeScript namespace paths qualify their +contained type IDs. Type metadata records a label-based `type_path`, including +C# generic arity. Exported field tables use this path and namespace, not raw +node IDs, to survive cache relocation and corpus ID remapping. + +Java preserves package/import context for qualified receivers, typed +locals/parameters/fields, casts, and declared factory returns. Nested member +types inherited from resolved supertypes participate in lexical lookup; ambiguous +inherited types stay unlinked. Qualified type paths bind their lexical prefix +before following member types, without falling back to an unrelated owner. +Inherited field types, including static-field chains, resolve in their declaring +type's package/import and lexical context rather than the calling subclass's. +C# passes the complete declared receiver name to +its namespace/alias/nested-type resolver, honoring lexical shadowing before +absolute namespace matches. Qualified member-type lookup includes resolved +base classes, with directly declared nested types hiding inherited candidates. +Kotlin declarations persist their package as namespace metadata so explicit +singleton imports also resolve against unchanged files; import aliases constrain +only their local binding name. Swift preserves qualified nested receiver names +in static calls and parameter/property annotations and resolves names in +the caller's lexical type scopes before module-level definitions; an unrelated +nested declaration does not shadow a top-level type. Qualified constructor +initializers retain their complete type path for local receiver typing without +reintroducing bare-method fallback. Static calls do not require +a local variable-type table. Ambiguous or absent +explicit targets stay unlinked rather than falling back to an unrelated name. + +AST cache schema 47 retires flat nested IDs and incomplete scope facts. +Unchanged type metadata is preserved by CLI/watch incremental resolution. +Existing persisted graphs and source-evidence artifacts with old IDs require +a full code rebuild: `graphify extract . --code-only --force`. + Maven manifest ingestion parses XML namespace declarations before normalizing tags in the root namespace for package and dependency lookups. Default and prefixed declarations share the same package identity; foreign namespaces and diff --git a/graphify/cache.py b/graphify/cache.py index 1125f02d92..7890b17d54 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -38,7 +38,8 @@ _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 45 # A C# field or primary-constructor parameter whose type name does not start with a capital (`_Pair`, `pair`, `bool`) is recorded in the receiver table, and a member call on a receiver that no local, parameter, field or property declares carries `receiver_undeclared`, so `_Pair.Make()`/`pair.Create()` bind as static calls (#5174); a Java call through a class-qualified static field (`Service.INSTANCE.get()`) resolves via the field's declared type, recorded in a static-field type table (#5149); and a SQL routine body preserves comma joins and keeps a `@dblink` suffix distinct, an unparsed derived table's alias is no longer read as a table, and Oracle `[NON]EDITIONABLE` PROCEDURE/FUNCTION/TRIGGER DDL is recovered instead of yielding only a file node (#5169, #5147, #5167); so cached C#, Java and SQL extractions re-run; prior (44): A folded C++ parameterless member records its `definition_file` anchored to the source root instead of a cache-root-relative `../`-walk; a PHP route closure takes its path only from the arguments before it, so a string argument after the closure no longer becomes the route's path or leaks into an enclosing group's prefix (#5135); two module-level declarations in one file differing only by a leading/trailing `_` keep distinct ids (the first keeps the plain id, the rest are salted) across JS, TS, C, C++, PHP, Ruby, Kotlin, Swift and Lua (#2810); PHP methods of one class/trait/interface/enum differing only by a `_` keep distinct ids (#5137); and Java members differing only by a `_` or only by case keep distinct ids (#5151); so cached C++, PHP, Java and module-level-id extractions re-run; prior (43): A dotted Python subscript annotation (`mod.MyBox[Foo]`) walks its value once as the type, not again as its own generic argument, so the false `generic_arg` reference no longer replaces the real `parameter_type`/`return_type` edge, a C++ plain class/struct forward folds into its unique exactly-qualified definition when one file reaches the other through unconditional exact quoted includes, and a parameterless member declaration folds into its proven out-of-line definition by matching return type/qualifiers, with per-node `_cpp_member_records`/`_cpp_class_kind` facts persisted (#5117); a C++ member receiver resolves through a scoped type alias (`using Alias = ns::Type;`) and through inherited fields/methods, with forward-declared pointee types completed at the use site via a lexical-scope lookup, fail-closed on ambiguous/virtual/diamond bases (#5118); and a C++ out-of-line template member keeps its proven primary-template owner while calls in a `#if`-guarded initializer or argument list are recovered from byte-preserving branch views, with dependent receivers left unresolved (#5115); so cached Python and C++ extractions re-run; prior (42): A bare, unqualified free-function mention in a Markdown document (`token` in prose, no `()` and no namespace) binds to a same-named function only when the defining file, or another symbol from it, is cited within the heading's section chain, instead of matching any same-named callable anywhere in the corpus; reference-style links (`[text][label]`) resolve at the use site (#4804, #4806); and a C# classic extension-method call (`x.Foo()` where `Foo` is a `static ... Foo(this X x, ...)`) binds to the extension's declaration through a post-nominal additive pass, gated to a visible namespace, a matching `this` type and a unique applicable method, with a BCL deny-list and fail-closed on ambiguity or an instance-method shadow (#4828); so cached Markdown and C# extractions re-run; prior (41): A C++ member-call chain routed through class fields declared in other headers (`client.actions->Execute()`, with value `.`, raw-pointer `->` and single-arg `unique_ptr`/`shared_ptr` receivers) resolves to the field type's member across the include set, gated to a unique type and member and fail-closed otherwise, and class fields are persisted on the node (#5098); and a Markdown code span naming an uppercase `\`-namespace class (`\Foo\Bar`, `App\API`) or a type-qualified method call (`Type::member()`) is kept as a symbol mention instead of being dropped, while a non-call `Type::CONST`/`Type::class` stays dropped (#5093); so cached C++ and Markdown extractions re-run; prior (40): Solidity members, free functions and types whose names differ only by a leading `_` (or by case) in one scope (`_grantRole(bytes32,address)` / `grantRole(bytes32,address)`) get separate node ids: the one public name keeps its id and the others are salted, as in Python and C# (#3302, #4415), so the `_` function no longer vanishes and its calls stop landing on its twin (#5027); a Solidity call on a receiver of a contract or interface type (`IERC20 token; token.transfer()`, `IERC20(a).transfer()`, `token().balanceOf()`) binds by name and arity through that type's linearization to the type's own member instead of producing no edge, gated to a single visible contract/interface and fail-closed on an unknown or ambiguous type (#5060); a Solidity call attached by a `using A for T` directive (`using SafeCast for uint256; x.toUint48()`) binds to the library or free function it attaches, matched by the receiver's type and an arity one greater than the call (the receiver), fail-closed when the type is unknown or two functions fit (#5062); a C# constructor (instance, static or primary) is its own node owned by its type through a `method` edge, its body and `: base(...)`/`: this(...)` calls credited to it, and `new X(...)`/`: base`/`: this` additively link the arity-correct overload (#5054); and a C# call inside a property/indexer accessor, expression body, operator or conversion is credited to that member (an indexer is a `this[]` node, an operator a `.operator +()` node) instead of being lost (#5056), so cached Solidity and C# extractions re-run; prior (39): A Markdown code span written as a backslash namespace (`\Foo\Bar`) or a type-qualified member (`Type::member`) is recognised as a symbol mention and emits a `ns_qualified` raw_call (#4610, #4695), so cached Markdown extractions re-run; prior (38): C# preprocessor directives and `#if` branches that would derail tree-sitter-c-sharp are blanked before parsing (byte-length preserving, so lines and columns do not move), so a directive no longer closes the enclosing class early and drops later members or calls (#5003); a Solidity library call (`Lib.f()`, including an aliased import), and an `emit`/`revert`/`require` naming a declared event or error, link to the member they name instead of producing no edge, resolved only when a single visible type and a unique name/arity member match (#5001, #5005); and a YAML `<<` merge sequence gives the earlier anchor precedence for a duplicated key, matching the merge spec and PyYAML, so the winning key carries its correct source line (#4995); so cached C#, Solidity and YAML extractions re-run; prior (37): Solidity `import "../x/Y.sol"` resolves the `..` to the real file instead of a dangling stub that kept the importer's own folder (#4985); a Python module-level singleton (`g = Service()` then `g.method()`) resolves its member calls through the instance's type, gated by an exact-spelling and lexical-shadow guard (#4972); Kotlin resolves a call to an overloaded method and attributes calls inside an `init {}` block to the class, and a bare call no longer binds to a class the caller cannot reach (#4935, #4956); a Go same-file member call resolves through the receiver's type instead of a declaration-order-dependent same-named method (#4962, #4966); a Go member call on a receiver typed from another package (`pkg.T`) binds only to the `T` declared at the path the file imports `pkg` from (#3368, #4853); and a Go interface method call additively dispatches to the interface's implicit implementers (#4934), so cached Solidity, Python, Kotlin and Go extractions re-run; prior (36): C++ extraction changes: a type-alias reference (`using Foo = Bar;` then `Foo x;`) binds by exact spelling so it no longer loses to a same-normalized function, and a salted alias keeps its own `contains` edge (#4951); a member call `x.m()` binds by the receiver's type, now typed from parameters and class fields and not just locals, with untyped receivers kept bound in-file (#3215, #4946); an out-of-line/split-header definition attaches to the unique owner whose class file is in its include set via a `_cpp_qualified` identity marker, instead of file-containing or cross-binding to an unrelated same-named function (#4916, #4920); and a local callable value (a lambda or `std::function` local) is kept out of global call resolution by byte-range binding lifetimes so it no longer mints a spurious INFERRED call to a same-named global (#4917, #4921), so cached C++ extractions re-run; prior (35): A Rust module-qualified call like `crate::b::helper()` resolves to the function it names via qualifier-verified cross-file resolution, kept inside the caller's crate and out of local names (#2135, #4944); a Rust call written inside an expression macro's arguments (`println!`, `vec!`, ...) is linked, with a parsed call winning the dedupe (#4945); and cache keys keep their written case (file I/O through os_long_path, cache identity via normpath not normcase, and the digest salt no longer lowercased) so README.md and readme.md stop sharing one digest on a case-sensitive filesystem, which also changes the salt digest for any relative path carrying an uppercase char (#4925, #4937), so cached Rust extractions and any uppercase-path cache entries re-run; prior (34): A bare call inside a Python method no longer self-binds to a same-named method, so it resolves to the imported/module function instead (#4933); a Go method value reached through a receiver field is linked as an indirect_call (#4931); and a Go package-level `var` initialiser references the types it names (#4932), so cached Python and Go extractions re-run; prior (33): A Scala `extension (x: T)` method is scoped to its receiver type T (#2044, #4856); and a Terraform node carries its original scope so ids stay portable when the cached project is moved to a new directory (#4858, #4861), so cached Scala and Terraform extractions re-run; prior (32): A type and a function whose names differ only in case get separate node ids (#4904, #4908); and a PHP `Scope::MEMBER` access in a class-level initializer (const value, property/parameter default) emits a references_constant edge (#4648, #4895); R `setClass`/`setRefClass` `contains=` superclasses get inherits edges (#4902); an Elixir bare call resolves to the caller's own module first (#4900); a Dart mixin `on A, B` clause resolves every constraint (#4899); and a Fortran derived-type `extends` emits an inherits edge (#4897), so cached extractions for those languages re-run (#4904); Go credits a call in a package-level `var` initialiser (and its closures) to the file node (#4867); a Go method value passed as an argument is linked as an `indirect_call` (#4868); a Go receiver's field whose struct is declared in a sibling file of the same package is typed so its member calls resolve (#4866); and a Python dispatch table's `module.func` values resolve to the referenced functions as `indirect_call`s (#4773), so cached Go and Python extractions re-run; prior (30): File and per-declaration ids are kept distinct so a top-level declaration sharing the file's name is not dropped (#4854); a SystemVerilog `function new(...)` constructor gets its own node, `method` edge and parameter refs (#4848); a Go `var x = T{}`/`var x = &T{}` types `x` as `T` for receiver resolution like `x := T{}` (#4843); a PowerShell `using module`/`using assembly` name drops its file extension (#4847); a Solidity free-function `using {a, b} for T` set emits `uses` edges (#4846); a CommonLisp forward-referenced superclass binds to its local node instead of a sourceless stub (#4845); and Pascal `inherited`/`inline` form component blocks are recognised so later `object` blocks keep their parent (#4844), so cached extractions across those languages re-run; prior (29): OCaml `and`-chained module/class bindings are all walked (only the first was, so later bindings were silently dropped, #4822), and a SystemVerilog class `task` method is modeled like a `function` one with its own node and parameter refs while its task-locals no longer leak as class fields (#4774), so cached OCaml and Verilog/SystemVerilog extractions re-run; prior (28): Markdown ATX headings parse per CommonMark (closing `#` runs dropped, 0-3 space indent, hash-only lines mint no node, #4790) and fenced code blocks are tracked by CommonMark rules so headings inside fences stop leaking (#4787); YAML keys carry their written spelling and real line and `on`/`off`/`yes`/`no` keys no longer fold to booleans (#4784); rationale labels redact more credential shapes before caching (#4647); Razor `@implements` interface edges are extracted (#4820); aliased Terraform providers get their own node and `provider =` refs resolve (#4819); COBOL paragraphs after a nested `END PROGRAM` reattach to the enclosing program (#4824); Blade `wire:click` binds the bare method name (#4823); a Pascal record is a type with its own methods (#4825); VB.NET operator declarations are extracted as members with their bodies walked (#4821); and a Fortran call resolves to the subroutine it names (#4795), so cached Markdown/YAML/rationale/Razor/Terraform/COBOL/Blade/Pascal/VB.NET/Fortran extractions re-run; prior (27): VB.NET external namespace/type stubs now carry a `source_file` sentinel (#4725); Python member calls resolve through injected fields and method-result types (#4726); a Rust `use` is scoped to the crate it names (#4699), so cached VB.NET/Python/Rust extractions re-run; prior (26): Zig no longer crosses a call between a method and a same-named free function (#4755), and a Dart unnamed `extension on T` is detected (#4741), so cached Zig and Dart extractions re-run; prior (25): Python/Go/C++/Rust explicit type aliases (and Rust `union` items) are extracted as type nodes so references resolve instead of minting sourceless stubs (#4578/#4579/#4580/#4581); each C# method overload gets its own node bound by arity (#4584); a package.json dependency's manifest entry links to its ref node (#4569), so cached extractions re-run; prior (24): Objective-C `NS_CLOSED_ENUM` declarations are extracted as enums like `NS_ENUM` (#4705), and a `CREATE TABLE` whose column default the SQL grammar rejects (MySQL `DEFAULT current_timestamp()`) is recovered from its ERROR node (#4613), so cached ObjC and SQL extractions re-run; prior (23): `.yaml`/`.yml` files are structurally extracted (keys as nodes, config-vs-data gated) and reclassified from document to code (#2214), so cached YAML re-extracts; prior (22): SQL labels drop T-SQL brackets and routine bodies emit `reads_from`/`writes_to` (#4635), and a `GO` batch separator ends a statement (#4651), so cached `.sql` extractions re-run; prior (21): a CommonJS `const X = require('./m')` binding no longer mints a module-scope node that shadows the class the required file exports, so `new X()` resolves to the required declaration (#4621); prior (20): Obsidian `[[wikilink]]` targets that start with the scan root's own folder names now resolve to the real page (#4559), so cached markdown extractions re-run; prior (19): Dart `typedef` declarations are extracted as aliases (not variables of type `typedef`); C++ Metal/CUDA GPU qualifiers no longer mint sourceless god nodes (#4550); a `.h` header containing `extern "C"` routes to the C++ extractor; prior (18): Kotlin `typealias` (#4553), C# `delegate` (#4552), and Swift `macro` declarations (#4551) are now extracted so references stop minting sourceless stubs; Rust `use` rooted at a `mod` declared in the same scope carries `_rust_use_name` (#4564); prior (17): Python pytest-fixture markers (`_py_fixture`/`_python_fixture_param`, #4332) and module-level class-id collision salting (#2810); TS `this.field` typed from any method incl. static (#4473); Solidity callables carry `metadata.arity` + inherited-call/`super`/modifier raw calls (#4379); Go type-decl and Rust file-item case-distinct node ids (#4444); prior (16): Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids. +# Schema 46 preserves Java qualified receiver names and enclosing nested-type identities (#5185). +_AST_CACHE_SCHEMA = 47 # Owner-qualified nested type IDs, scoped receiver binding, and portable field-table paths (#5185); prior (46): A C# field or primary-constructor parameter whose type name does not start with a capital (`_Pair`, `pair`, `bool`) is recorded in the receiver table, and a member call on a receiver that no local, parameter, field or property declares carries `receiver_undeclared`, so `_Pair.Make()`/`pair.Create()` bind as static calls (#5174); a Java call through a class-qualified static field (`Service.INSTANCE.get()`) resolves via the field's declared type, recorded in a static-field type table (#5149); and a SQL routine body preserves comma joins and keeps a `@dblink` suffix distinct, an unparsed derived table's alias is no longer read as a table, and Oracle `[NON]EDITIONABLE` PROCEDURE/FUNCTION/TRIGGER DDL is recovered instead of yielding only a file node (#5169, #5147, #5167); so cached C#, Java and SQL extractions re-run; prior (44): A folded C++ parameterless member records its `definition_file` anchored to the source root instead of a cache-root-relative `../`-walk; a PHP route closure takes its path only from the arguments before it, so a string argument after the closure no longer becomes the route's path or leaks into an enclosing group's prefix (#5135); two module-level declarations in one file differing only by a leading/trailing `_` keep distinct ids (the first keeps the plain id, the rest are salted) across JS, TS, C, C++, PHP, Ruby, Kotlin, Swift and Lua (#2810); PHP methods of one class/trait/interface/enum differing only by a `_` keep distinct ids (#5137); and Java members differing only by a `_` or only by case keep distinct ids (#5151); so cached C++, PHP, Java and module-level-id extractions re-run; prior (43): A dotted Python subscript annotation (`mod.MyBox[Foo]`) walks its value once as the type, not again as its own generic argument, so the false `generic_arg` reference no longer replaces the real `parameter_type`/`return_type` edge, a C++ plain class/struct forward folds into its unique exactly-qualified definition when one file reaches the other through unconditional exact quoted includes, and a parameterless member declaration folds into its proven out-of-line definition by matching return type/qualifiers, with per-node `_cpp_member_records`/`_cpp_class_kind` facts persisted (#5117); a C++ member receiver resolves through a scoped type alias (`using Alias = ns::Type;`) and through inherited fields/methods, with forward-declared pointee types completed at the use site via a lexical-scope lookup, fail-closed on ambiguous/virtual/diamond bases (#5118); and a C++ out-of-line template member keeps its proven primary-template owner while calls in a `#if`-guarded initializer or argument list are recovered from byte-preserving branch views, with dependent receivers left unresolved (#5115); so cached Python and C++ extractions re-run; prior (42): A bare, unqualified free-function mention in a Markdown document (`token` in prose, no `()` and no namespace) binds to a same-named function only when the defining file, or another symbol from it, is cited within the heading's section chain, instead of matching any same-named callable anywhere in the corpus; reference-style links (`[text][label]`) resolve at the use site (#4804, #4806); and a C# classic extension-method call (`x.Foo()` where `Foo` is a `static ... Foo(this X x, ...)`) binds to the extension's declaration through a post-nominal additive pass, gated to a visible namespace, a matching `this` type and a unique applicable method, with a BCL deny-list and fail-closed on ambiguity or an instance-method shadow (#4828); so cached Markdown and C# extractions re-run; prior (41): A C++ member-call chain routed through class fields declared in other headers (`client.actions->Execute()`, with value `.`, raw-pointer `->` and single-arg `unique_ptr`/`shared_ptr` receivers) resolves to the field type's member across the include set, gated to a unique type and member and fail-closed otherwise, and class fields are persisted on the node (#5098); and a Markdown code span naming an uppercase `\`-namespace class (`\Foo\Bar`, `App\API`) or a type-qualified method call (`Type::member()`) is kept as a symbol mention instead of being dropped, while a non-call `Type::CONST`/`Type::class` stays dropped (#5093); so cached C++ and Markdown extractions re-run; prior (40): Solidity members, free functions and types whose names differ only by a leading `_` (or by case) in one scope (`_grantRole(bytes32,address)` / `grantRole(bytes32,address)`) get separate node ids: the one public name keeps its id and the others are salted, as in Python and C# (#3302, #4415), so the `_` function no longer vanishes and its calls stop landing on its twin (#5027); a Solidity call on a receiver of a contract or interface type (`IERC20 token; token.transfer()`, `IERC20(a).transfer()`, `token().balanceOf()`) binds by name and arity through that type's linearization to the type's own member instead of producing no edge, gated to a single visible contract/interface and fail-closed on an unknown or ambiguous type (#5060); a Solidity call attached by a `using A for T` directive (`using SafeCast for uint256; x.toUint48()`) binds to the library or free function it attaches, matched by the receiver's type and an arity one greater than the call (the receiver), fail-closed when the type is unknown or two functions fit (#5062); a C# constructor (instance, static or primary) is its own node owned by its type through a `method` edge, its body and `: base(...)`/`: this(...)` calls credited to it, and `new X(...)`/`: base`/`: this` additively link the arity-correct overload (#5054); and a C# call inside a property/indexer accessor, expression body, operator or conversion is credited to that member (an indexer is a `this[]` node, an operator a `.operator +()` node) instead of being lost (#5056), so cached Solidity and C# extractions re-run; prior (39): A Markdown code span written as a backslash namespace (`\Foo\Bar`) or a type-qualified member (`Type::member`) is recognised as a symbol mention and emits a `ns_qualified` raw_call (#4610, #4695), so cached Markdown extractions re-run; prior (38): C# preprocessor directives and `#if` branches that would derail tree-sitter-c-sharp are blanked before parsing (byte-length preserving, so lines and columns do not move), so a directive no longer closes the enclosing class early and drops later members or calls (#5003); a Solidity library call (`Lib.f()`, including an aliased import), and an `emit`/`revert`/`require` naming a declared event or error, link to the member they name instead of producing no edge, resolved only when a single visible type and a unique name/arity member match (#5001, #5005); and a YAML `<<` merge sequence gives the earlier anchor precedence for a duplicated key, matching the merge spec and PyYAML, so the winning key carries its correct source line (#4995); so cached C#, Solidity and YAML extractions re-run; prior (37): Solidity `import "../x/Y.sol"` resolves the `..` to the real file instead of a dangling stub that kept the importer's own folder (#4985); a Python module-level singleton (`g = Service()` then `g.method()`) resolves its member calls through the instance's type, gated by an exact-spelling and lexical-shadow guard (#4972); Kotlin resolves a call to an overloaded method and attributes calls inside an `init {}` block to the class, and a bare call no longer binds to a class the caller cannot reach (#4935, #4956); a Go same-file member call resolves through the receiver's type instead of a declaration-order-dependent same-named method (#4962, #4966); a Go member call on a receiver typed from another package (`pkg.T`) binds only to the `T` declared at the path the file imports `pkg` from (#3368, #4853); and a Go interface method call additively dispatches to the interface's implicit implementers (#4934), so cached Solidity, Python, Kotlin and Go extractions re-run; prior (36): C++ extraction changes: a type-alias reference (`using Foo = Bar;` then `Foo x;`) binds by exact spelling so it no longer loses to a same-normalized function, and a salted alias keeps its own `contains` edge (#4951); a member call `x.m()` binds by the receiver's type, now typed from parameters and class fields and not just locals, with untyped receivers kept bound in-file (#3215, #4946); an out-of-line/split-header definition attaches to the unique owner whose class file is in its include set via a `_cpp_qualified` identity marker, instead of file-containing or cross-binding to an unrelated same-named function (#4916, #4920); and a local callable value (a lambda or `std::function` local) is kept out of global call resolution by byte-range binding lifetimes so it no longer mints a spurious INFERRED call to a same-named global (#4917, #4921), so cached C++ extractions re-run; prior (35): A Rust module-qualified call like `crate::b::helper()` resolves to the function it names via qualifier-verified cross-file resolution, kept inside the caller's crate and out of local names (#2135, #4944); a Rust call written inside an expression macro's arguments (`println!`, `vec!`, ...) is linked, with a parsed call winning the dedupe (#4945); and cache keys keep their written case (file I/O through os_long_path, cache identity via normpath not normcase, and the digest salt no longer lowercased) so README.md and readme.md stop sharing one digest on a case-sensitive filesystem, which also changes the salt digest for any relative path carrying an uppercase char (#4925, #4937), so cached Rust extractions and any uppercase-path cache entries re-run; prior (34): A bare call inside a Python method no longer self-binds to a same-named method, so it resolves to the imported/module function instead (#4933); a Go method value reached through a receiver field is linked as an indirect_call (#4931); and a Go package-level `var` initialiser references the types it names (#4932), so cached Python and Go extractions re-run; prior (33): A Scala `extension (x: T)` method is scoped to its receiver type T (#2044, #4856); and a Terraform node carries its original scope so ids stay portable when the cached project is moved to a new directory (#4858, #4861), so cached Scala and Terraform extractions re-run; prior (32): A type and a function whose names differ only in case get separate node ids (#4904, #4908); and a PHP `Scope::MEMBER` access in a class-level initializer (const value, property/parameter default) emits a references_constant edge (#4648, #4895); R `setClass`/`setRefClass` `contains=` superclasses get inherits edges (#4902); an Elixir bare call resolves to the caller's own module first (#4900); a Dart mixin `on A, B` clause resolves every constraint (#4899); and a Fortran derived-type `extends` emits an inherits edge (#4897), so cached extractions for those languages re-run (#4904); Go credits a call in a package-level `var` initialiser (and its closures) to the file node (#4867); a Go method value passed as an argument is linked as an `indirect_call` (#4868); a Go receiver's field whose struct is declared in a sibling file of the same package is typed so its member calls resolve (#4866); and a Python dispatch table's `module.func` values resolve to the referenced functions as `indirect_call`s (#4773), so cached Go and Python extractions re-run; prior (30): File and per-declaration ids are kept distinct so a top-level declaration sharing the file's name is not dropped (#4854); a SystemVerilog `function new(...)` constructor gets its own node, `method` edge and parameter refs (#4848); a Go `var x = T{}`/`var x = &T{}` types `x` as `T` for receiver resolution like `x := T{}` (#4843); a PowerShell `using module`/`using assembly` name drops its file extension (#4847); a Solidity free-function `using {a, b} for T` set emits `uses` edges (#4846); a CommonLisp forward-referenced superclass binds to its local node instead of a sourceless stub (#4845); and Pascal `inherited`/`inline` form component blocks are recognised so later `object` blocks keep their parent (#4844), so cached extractions across those languages re-run; prior (29): OCaml `and`-chained module/class bindings are all walked (only the first was, so later bindings were silently dropped, #4822), and a SystemVerilog class `task` method is modeled like a `function` one with its own node and parameter refs while its task-locals no longer leak as class fields (#4774), so cached OCaml and Verilog/SystemVerilog extractions re-run; prior (28): Markdown ATX headings parse per CommonMark (closing `#` runs dropped, 0-3 space indent, hash-only lines mint no node, #4790) and fenced code blocks are tracked by CommonMark rules so headings inside fences stop leaking (#4787); YAML keys carry their written spelling and real line and `on`/`off`/`yes`/`no` keys no longer fold to booleans (#4784); rationale labels redact more credential shapes before caching (#4647); Razor `@implements` interface edges are extracted (#4820); aliased Terraform providers get their own node and `provider =` refs resolve (#4819); COBOL paragraphs after a nested `END PROGRAM` reattach to the enclosing program (#4824); Blade `wire:click` binds the bare method name (#4823); a Pascal record is a type with its own methods (#4825); VB.NET operator declarations are extracted as members with their bodies walked (#4821); and a Fortran call resolves to the subroutine it names (#4795), so cached Markdown/YAML/rationale/Razor/Terraform/COBOL/Blade/Pascal/VB.NET/Fortran extractions re-run; prior (27): VB.NET external namespace/type stubs now carry a `source_file` sentinel (#4725); Python member calls resolve through injected fields and method-result types (#4726); a Rust `use` is scoped to the crate it names (#4699), so cached VB.NET/Python/Rust extractions re-run; prior (26): Zig no longer crosses a call between a method and a same-named free function (#4755), and a Dart unnamed `extension on T` is detected (#4741), so cached Zig and Dart extractions re-run; prior (25): Python/Go/C++/Rust explicit type aliases (and Rust `union` items) are extracted as type nodes so references resolve instead of minting sourceless stubs (#4578/#4579/#4580/#4581); each C# method overload gets its own node bound by arity (#4584); a package.json dependency's manifest entry links to its ref node (#4569), so cached extractions re-run; prior (24): Objective-C `NS_CLOSED_ENUM` declarations are extracted as enums like `NS_ENUM` (#4705), and a `CREATE TABLE` whose column default the SQL grammar rejects (MySQL `DEFAULT current_timestamp()`) is recovered from its ERROR node (#4613), so cached ObjC and SQL extractions re-run; prior (23): `.yaml`/`.yml` files are structurally extracted (keys as nodes, config-vs-data gated) and reclassified from document to code (#2214), so cached YAML re-extracts; prior (22): SQL labels drop T-SQL brackets and routine bodies emit `reads_from`/`writes_to` (#4635), and a `GO` batch separator ends a statement (#4651), so cached `.sql` extractions re-run; prior (21): a CommonJS `const X = require('./m')` binding no longer mints a module-scope node that shadows the class the required file exports, so `new X()` resolves to the required declaration (#4621); prior (20): Obsidian `[[wikilink]]` targets that start with the scan root's own folder names now resolve to the real page (#4559), so cached markdown extractions re-run; prior (19): Dart `typedef` declarations are extracted as aliases (not variables of type `typedef`); C++ Metal/CUDA GPU qualifiers no longer mint sourceless god nodes (#4550); a `.h` header containing `extern "C"` routes to the C++ extractor; prior (18): Kotlin `typealias` (#4553), C# `delegate` (#4552), and Swift `macro` declarations (#4551) are now extracted so references stop minting sourceless stubs; Rust `use` rooted at a `mod` declared in the same scope carries `_rust_use_name` (#4564); prior (17): Python pytest-fixture markers (`_py_fixture`/`_python_fixture_param`, #4332) and module-level class-id collision salting (#2810); TS `this.field` typed from any method incl. static (#4473); Solidity callables carry `metadata.arity` + inherited-call/`super`/modifier raw calls (#4379); Go type-decl and Rust file-item case-distinct node ids (#4444); prior (16): Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids. # Per-process memo: distributions() scans site-packages metadata, which is diff --git a/graphify/cli.py b/graphify/cli.py index 690214576e..3ca923b607 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -5091,7 +5091,7 @@ def _ctx_identity(source_file) -> str | None: "_cpp_member_records", "_cpp_lookup", "_cpp_field_positions", "_cpp_type_bindings", "_cpp_include_positions", "_cpp_include_paths", "_rust_impl_key", "_rust_declaration_count", - "_ts_public_twin", "_py_property", "_java_ret", + "_ts_public_twin", "_py_property", "_java_ret", "_java_type", "_ts_fields", "_ts_static_fields", "_py_fixture", "_py_return_type", "_go_fields", "_go_package_path", @@ -5110,6 +5110,11 @@ def _ctx_identity(source_file) -> str | None: "cpp_conditions", "ruby_reopened", "ruby_external_method_owners", + "type_path", + "namespace", + "scope_chain", + "is_nested_type", + "is_partial", # Erlang remote-call resolution keys (#3714): # an unchanged callee module must keep its # module/name/arity so `foo:bar()` still @@ -5119,6 +5124,9 @@ def _ctx_identity(source_file) -> str | None: "module", "name", "arity", + "min_arity", + "variadic", + "signature", ) if key in _metadata } diff --git a/graphify/extract.py b/graphify/extract.py index 20c2b2a09b..3f11ecd540 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5509,9 +5509,6 @@ def _resolve_swift_member_calls( tt = result.get("swift_type_table") if tt and tt.get("path"): type_table_by_file[tt["path"]] = tt.get("table", {}) - if not type_table_by_file: - return - def _key(label: str) -> str: return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() @@ -5524,11 +5521,52 @@ def _key(label: str) -> str: # Type name -> definition node ids (real, source-backed, type-like defs only). # len != 1 is the god-node guard: an ambiguous type name bails. type_def_nids: dict[str, list[str]] = {} + top_level_type_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (str(n.get("source_file", "")).lower().endswith(".swift") + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) + path = (n.get("metadata") or {}).get("type_path", n.get("label", "")) + if "." not in path: + top_level_type_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) + + type_ids = {nid for definitions in type_def_nids.values() for nid in definitions} + lexical_parents: dict[str, str] = {} + nested_types: dict[tuple[str, str], set[str]] = {} + for edge in all_edges: + parent, child = edge.get("source"), edge.get("target") + if not isinstance(parent, str) or not isinstance(child, str) or parent not in type_ids: + continue + if edge.get("relation") == "contains" and child in type_ids: + lexical_parents[child] = parent + nested_types.setdefault( + (parent, _key(node_by_id[child].get("label", ""))), set(), + ).add(child) + elif edge.get("relation") == "method": + lexical_parents[child] = parent + + def scoped_type_definitions(name: str, caller: str) -> list[str]: + if "." in name: + first, _, rest = name.partition(".") + definitions = scoped_type_definitions(first, caller) + for segment in rest.split("."): + if len(definitions) != 1: + return [] + definitions = sorted(nested_types.get((definitions[0], _key(segment)), ())) + return definitions + scope = lexical_parents.get(caller) + seen: set[str] = set() + while scope and scope not in seen: + seen.add(scope) + nested = nested_types.get((scope, _key(name))) + if nested: + return sorted(nested) + if _key(node_by_id[scope].get("label", "")) == _key(name): + return [scope] + scope = lexical_parents.get(scope) + return top_level_type_nids.get(_key(name), []) # (type_node_id, method_key) -> method_node_id, from `method` edges. method_index: dict[tuple[str, str], str] = {} @@ -5596,7 +5634,8 @@ def _key(label: str) -> str: existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} for rc in all_raw_calls: - if not rc.get("is_member_call"): + if (not rc.get("is_member_call") + or not str(rc.get("source_file", "")).lower().endswith(".swift")): continue receiver = rc.get("receiver") callee = rc.get("callee") @@ -5620,7 +5659,7 @@ def _key(label: str) -> str: # do the same for Swift (#2147). if type_name in _LANGUAGE_BUILTIN_GLOBALS: continue - type_defs = type_def_nids.get(_key(type_name), []) + type_defs = scoped_type_definitions(type_name, caller) if not type_defs: # Declared nowhere here — in a multi-repo setup that usually means "in # a repo this build does not contain", so park it for the merge @@ -6832,6 +6871,7 @@ def chain_park_name(rc: dict) -> str | None: def _csharp_keyword_type(name: str) -> str: """The C# keyword spelling of a built-in type name, else ``name`` unchanged.""" + name = name.removeprefix("global::") bare = name[len("System."):] if name.startswith("System.") else name return _CSHARP_SYSTEM_TYPE_KEYWORDS.get(bare, bare if bare in _CSHARP_KEYWORD_TYPE_ALIASES else name) @@ -7397,6 +7437,11 @@ def _resolve_type_nid(type_name: str | None, caller_node: dict | None, if resolved: return resolved, True if decisive: + if (type_name.removeprefix("global::").startswith("System.") + and _csharp_keyword_type(type_name) in _CSHARP_KEYWORD_TYPE_ALIASES + and (type_name.startswith("global::") + or "System" not in resolver.aliases_by_file.get(src_file, {}))): + return None, False return None, True hits = type_def_nids.get(_key(type_name), []) if len(hits) == 1: @@ -7577,6 +7622,12 @@ def _bind_member_field_tables( if not label or not isinstance(fields, dict): continue candidates = by_label.get(str(label), []) + if entry.get("type_path"): + candidates = [ + n for n in candidates + if (n.get("metadata") or {}).get("type_path") == entry["type_path"] + and (n.get("metadata") or {}).get("namespace", "") == entry.get("namespace", "") + ] if len(candidates) > 1: sf = str(entry.get("source_file") or "") exact = [n for n in candidates if str(n.get("source_file")) == sf] @@ -7821,13 +7872,22 @@ def key(label: str) -> str: supertypes.setdefault(edge["source"], []).append(edge["target"]) class_fields = _bind_member_field_tables(per_file, all_nodes, lang="java") static_fields = _bind_member_field_tables(per_file, all_nodes, lang="java_static") + qualified_types: dict[str, list[str]] = {} + for node in all_nodes: + identity = node.get("_java_type") + if not isinstance(identity, dict) or node.get("id") not in contained: + continue + qualified = ".".join( + part for part in (identity.get("package"), identity.get("path")) if part + ) + qualified_types.setdefault(qualified, []).append(node["id"]) def _inherited_field_type( - class_nid, + class_nid: str | None, field: str, bases: dict[str, list[str]] = inherits_bases, tables: dict[str, dict[str, str]] | None = None, - ): + ) -> tuple[str, str] | None: fields = class_fields if tables is None else tables seen: set = set() queue = [class_nid] @@ -7838,7 +7898,7 @@ def _inherited_field_type( seen.add(cls) hit = fields.get(cls, {}).get(field) if hit: - return hit + return hit, cls queue.extend(bases.get(cls, [])) return None @@ -7920,18 +7980,93 @@ def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None: return next(iter(hits)) if len(hits) == 1 else None def _type_named(name: str, near: str | None) -> str | None: - # A name declared more than once (a nested `Builder` in many classes) - # is the one in the file that names it, or none. - defs = type_def_nids.get(key(name), []) - if len(defs) > 1: - here = node_by_id.get(near, {}).get("source_file") - defs = [nid for nid in defs if node_by_id.get(nid, {}).get("source_file") == here] - return defs[0] if len(defs) == 1 else None + name = key(name) + owner = enclosing_type.get(near or "", near) + context = node_by_id.get(owner, {}).get("_java_type", {}) + package = context.get("package", "") + + def lookup(qualified: str) -> list[str]: + return qualified_types.get(qualified, []) + + def unique(defs: list[str] | set[str]) -> str | None: + return next(iter(defs)) if len(defs) == 1 else None + + first, dot, rest = name.partition(".") + + def member_types(scope: str, member: str) -> set[str]: + hits: set[str] = set() + seen: set[str] = set() + frontier = [scope] + while frontier: + nid = frontier.pop() + if nid in seen: + continue + seen.add(nid) + identity = node_by_id.get(nid, {}).get("_java_type", {}) + if not identity: + continue + qualified = ".".join( + part for part in (identity.get("package"), identity.get("path"), member) if part + ) + declared = lookup(qualified) + if declared: + hits.update(declared) + else: + frontier.extend(supertypes.get(nid, [])) + return hits + + def nested_type(defs: list[str] | set[str], suffix: str) -> str | None: + target = unique(defs) + for member in suffix.split(".") if suffix else []: + if target is None: + return None + target = unique(member_types(target, member)) + return target + + path = context.get("path", "") + while path: + scope_name = ".".join(part for part in (package, path) if part) + scopes = lookup(scope_name) + if path.rsplit(".", 1)[-1] == first: + return nested_type(scopes, rest) + members = {nid for scope in scopes for nid in member_types(scope, first)} + if members: + return nested_type(members, rest) + path = path.rpartition(".")[0] + imports = context.get("imports", []) + explicit = [ + imported for imported in imports + if not imported.endswith(".*") and imported.rsplit(".", 1)[-1] == first + ] + if explicit: + imported_hits = { + nid for imported in explicit + for nid in lookup(imported) + } + return nested_type(imported_hits, rest) + for qualified in dict.fromkeys((".".join(part for part in (package, name) if part), name)): + parts = qualified.split(".") + for length in range(1, len(parts) + 1): + if hits := lookup(".".join(parts[:length])): + return nested_type(hits, ".".join(parts[length:])) + wildcard_hits = { + nid for imported in imports if imported.endswith(".*") + for nid in lookup(imported[:-1] + first) + } + if wildcard_hits: + return nested_type(wildcard_hits, rest) + if dot or imports: + return None + defs = type_def_nids.get(name, []) + return unique(defs) def _chain_type(chain: list, caller: str, depth: int = 0) -> str | None: kind = chain[0] if chain and depth <= 16 else None if kind == "type": return _type_named(chain[1], caller) + if kind == "field_type": + declared = _inherited_field_type(enclosing_type.get(caller), chain[1], supertypes) + return _type_named(*declared) if declared else None if kind == "this": return enclosing_type.get(caller) if kind == "super": @@ -7950,7 +8085,7 @@ def _chain_type(chain: list, caller: str, depth: int = 0) -> str | None: declared = ( _inherited_field_type(owner, chain[1], tables=static_fields) if owner else None ) - return _type_named(declared, owner) if declared else None + return _type_named(*declared) if declared else None return None for result in per_file: @@ -8004,19 +8139,29 @@ def _chain_type(chain: list, caller: str, depth: int = 0) -> str | None: exact = True else: type_name = raw_call.get("receiver_type") + type_context = caller + if field_name := raw_call.get("receiver_field"): + declared = _inherited_field_type(enclosing_type.get(caller), field_name, supertypes) + if not declared: + continue + type_name, type_context = declared if not type_name and raw_call.get("receiver_unbound"): - type_name = _inherited_field_type(enclosing_type.get(caller), receiver, supertypes) + declared = _inherited_field_type(enclosing_type.get(caller), receiver, supertypes) + if declared: + type_name, type_context = declared if not type_name and receiver[:1].isupper(): type_name = receiver exact = True if not type_name and receiver.startswith("this."): - type_name = _inherited_field_type( + declared = _inherited_field_type( enclosing_type.get(caller), receiver[len("this."):] ) + if declared: + type_name, type_context = declared if not type_name: continue - type_defs = type_def_nids.get(key(type_name), []) - if not type_defs: + type_nid = _type_named(type_name, type_context) + if not type_nid and not type_def_nids.get(key(type_name.rsplit(".", 1)[-1])): # The type is declared nowhere in this corpus, which in a # multi-repo setup usually means "in a repo this build does # not contain" rather than "does not exist" — park it for the @@ -8027,9 +8172,8 @@ def _chain_type(chain: list, caller: str, depth: int = 0) -> str | None: node_by_id.get(caller), callee, type_name, "java", raw_call, ) continue - if len(type_defs) != 1: + if not type_nid: continue - type_nid = type_defs[0] method_nid = _method_on_type_or_bases(type_nid, key(callee)) if not method_nid or method_nid == caller or (caller, method_nid) in existing_pairs: @@ -8469,12 +8613,10 @@ def _resolve_kotlin_import_targets( invoked directly in extract(), not via the tail registry run). """ pkg_results = _kotlin_package_index(per_file) - if not pkg_results: - return # package fqn -> {importable label -> [node ids]}. Member labels (leading # dot) are not importable as `P.Name`, and sourceless reference stubs are # not definitions; both are excluded so they can't shadow the real symbol. - pkg_symbols: dict[str, dict[str, list[str]]] = {} + pkg_symbols: dict[str, dict[str, set[str]]] = {} for pkg, results in pkg_results.items(): by_label = pkg_symbols.setdefault(pkg, {}) for result in results: @@ -8484,7 +8626,17 @@ def _resolve_kotlin_import_targets( label = str(n.get("label", "")) if not label or label.startswith("."): continue - by_label.setdefault(label.strip("()"), []).append(n["id"]) + by_label.setdefault(label.strip("()"), set()).add(n["id"]) + for node in all_nodes: + if not str(node.get("source_file", "")).endswith((".kt", ".kts")): + continue + metadata = node.get("metadata") or {} + package = metadata.get("namespace") + label = str(node.get("label", "")) + if package and label and not label.startswith(".") and node.get("type") != "namespace": + pkg_symbols.setdefault(package, {}).setdefault( + metadata.get("type_path", label.strip("()")), set(), + ).add(node["id"]) for e in all_edges: if e.get("relation") != "imports": continue @@ -8494,9 +8646,9 @@ def _resolve_kotlin_import_targets( pkg, _, name = str(fqn).rpartition(".") if not pkg or not name: continue - candidates = pkg_symbols.get(pkg, {}).get(name, []) + candidates = pkg_symbols.get(pkg, {}).get(name, set()) if len(candidates) == 1: # single-candidate guard: never fabricate - e["target"] = candidates[0] + e["target"] = next(iter(candidates)) _C_FAMILY_HEADER_EXTS = frozenset({".h", ".hpp", ".hh", ".hxx", ".cuh", ".inl"}) @@ -9510,12 +9662,30 @@ def _resolve_kotlin_member_calls( if name: types_by_name.setdefault(name, []).append(n["id"]) + type_ids = {nid for definitions in types_by_name.values() for nid in definitions} + imported_types: dict[str, dict[str, set[str]]] = {} + for edge in all_edges: + if edge.get("relation") != "imports": + continue + source_file = str(edge.get("source_file") or "") + if not source_file.endswith((".kt", ".kts")): + continue + fqn = (edge.get("metadata") or {}).get("target_fqn") + if isinstance(fqn, str) and fqn and not fqn.endswith(".*"): + local_name = (edge.get("metadata") or {}).get("alias") or fqn.rsplit(".", 1)[-1] + imported_types.setdefault(source_file, {}).setdefault( + local_name, set(), + ).add(edge["target"]) + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} for rc in raw: receiver = rc["kotlin_object_receiver"] callee = rc["callee"] caller = rc["caller_nid"] type_nids = types_by_name.get(receiver, []) + explicit = imported_types.get(rc.get("source_file", ""), {}).get(receiver) + if explicit is not None: + type_nids = sorted(explicit & type_ids) if not type_nids: # Object/class declared nowhere here — usually in a repo this build does # not contain, so park it for the merge (#3152/#3388). A builtin receiver @@ -12702,7 +12872,7 @@ def _looks_like_bash(result: object) -> bool: # edges — so genuine imported calls get promoted INFERRED -> EXTRACTED. The # tail registry run (run_language_resolvers below) would be too late. run_language_resolvers( - paths, per_file, all_nodes, all_edges, + paths, per_file, resolution_nodes, all_edges, resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER], ) diff --git a/graphify/extractors/csharp.py b/graphify/extractors/csharp.py index d7006cd718..37925a9776 100644 --- a/graphify/extractors/csharp.py +++ b/graphify/extractors/csharp.py @@ -320,6 +320,7 @@ def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None: self.aliases_by_file: dict[str, dict[str, list[tuple[str, str, str | None]]]] = {} self.static_usings_by_file: dict[str, list[tuple[str, str, str | None]]] = {} self.nested_types_by_holder: dict[str, dict[str, str]] = {} + self.base_types_by_holder: dict[str, list[str]] = {} self._holder_nid_cache: dict[str, str | None] = {} for edge in all_edges: @@ -362,6 +363,13 @@ def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None: nested_candidates: dict[tuple[str, str], dict[str, dict]] = {} for edge in all_edges: + if edge.get("relation") == "inherits": + holder_nid, base_nid = edge.get("source"), edge.get("target") + if ( + isinstance(holder_nid, str) and isinstance(base_nid, str) + and _is_cs_file(self.node_by_id.get(holder_nid, {}).get("source_file")) + ): + self.base_types_by_holder.setdefault(holder_nid, []).append(base_nid) if edge.get("relation") != "contains": continue target_nid = edge.get("target") @@ -467,6 +475,25 @@ def _resolve_alias(self, label: str, source_node: dict, source_file: str) -> str hits.add(hit) return next(iter(hits)) if len(hits) == 1 else None + def _resolve_nested_type(self, holder_nid: str, name: str) -> str | None: + hits: set[str] = set() + seen: set[str] = set() + frontier = [holder_nid] + while frontier: + nid = frontier.pop() + if nid in seen: + continue + seen.add(nid) + node = self.node_by_id.get(nid) + if not node or not _is_cs_file(node.get("source_file")): + return None + declared = self.nested_types_by_holder.get(nid, {}).get(name) + if declared: + hits.add(declared) + continue + frontier.extend(self.base_types_by_holder.get(nid, ())) + return next(iter(hits)) if len(hits) == 1 else None + def _resolve_holder_nid(self, target_fqn: str) -> str | None: base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn)).strip() if not base_fqn: @@ -487,7 +514,7 @@ def _resolve_holder_nid(self, target_fqn: str) -> str | None: parent_nid = self._resolve_holder_nid(prefix) if parent_nid: - nested_hit = self.nested_types_by_holder.get(parent_nid, {}).get(name) + nested_hit = self._resolve_nested_type(parent_nid, name) if nested_hit: self._holder_nid_cache[base_fqn] = nested_hit return nested_hit @@ -508,6 +535,24 @@ def resolve_type_name( * ``(None, False)`` — scoping knows nothing about the name; a caller may fall back (e.g. to the corpus-wide unique bare-name match). """ + if label.startswith("global::"): + return self._resolve_holder_nid(label.removeprefix("global::")), True + if "." in label: + first, _, rest = label.partition(".") + holder, decisive = self.resolve_type_name(first, source_node, source_file) + if holder: + for segment in rest.split("."): + holder = self._resolve_nested_type(holder, segment) + if holder is None: + break + return holder, True + if decisive and first not in self.aliases_by_file.get(source_file, {}): + return None, True + qualifier, _, name = label.rpartition(".") + qualified = self.resolve_qualified(name, qualifier, source_node, source_file) + if qualified: + return qualified, True + return (None if decisive else self._resolve_holder_nid(label)), True if label in self.aliases_by_file.get(source_file, {}): return self._resolve_alias(label, source_node, source_file), True candidates: list[str] = [] diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 22134d6e40..e2b9dd3636 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -356,11 +356,11 @@ def _csharp_pre_scan_name_collisions( collides. Same-file partial declarations of one type share an id, so their members are grouped together. Overloads are one name, never a collision. """ - type_names: dict[str, set[str]] = {} member_names: dict[str, set[str]] = {} type_decls: list[tuple[object, str, str]] = [] + type_scopes: dict[tuple[str, tuple[str, ...]], list[tuple[object, str]]] = {} - def _visit(n, ns: list[str]) -> None: + def _visit(n, ns: list[str], owners: tuple[str, ...] = ()) -> None: # Types are declared only at file level, in namespace and type bodies # and in #if wrappers around them, so only those are entered: the # recursion depth is the nesting depth of declarations, never of @@ -371,7 +371,7 @@ def _visit(n, ns: list[str]) -> None: ns_name = _csharp_namespace_name(c, source) body = c.child_by_field_name("body") if body is not None: - _visit(body, scoped + [ns_name] if ns_name else scoped) + _visit(body, scoped + [ns_name] if ns_name else scoped, owners) elif c.type == "file_scoped_namespace_declaration": # Applies to the rest of the file, as in the walk. ns_name = _csharp_namespace_name(c, source) @@ -381,17 +381,31 @@ def _visit(n, ns: list[str]) -> None: name_node = c.child_by_field_name("name") name = _read_text(name_node, source) if name_node is not None else "" if name: - plain = _make_id(stem, ".".join(scoped), _csharp_type_name_for_id(c, name)) - type_names.setdefault(plain, set()).add(name) - type_decls.append((c, plain, name)) - # Nested types take the namespace, not the enclosing type (ids - # omit the outer type), so they join the same groups. - _visit(c, scoped) + type_scopes.setdefault((".".join(scoped), owners), []).append((c, name)) + _visit(c, scoped, (*owners, _csharp_type_name_for_id(c, name))) elif c.type == "declaration_list" or c.type.startswith("preproc_"): - _visit(c, scoped) + _visit(c, scoped, owners) _visit(root_node, []) - type_groups = {nid: names for nid, names in type_names.items() if len(names) >= 2} + type_groups: dict[str, set[str]] = {} + owner_ids: dict[tuple[str, tuple[str, ...]], str] = {} + for (namespace, owners), declarations in sorted( + type_scopes.items(), key=lambda item: len(item[0][1]), + ): + parent = owner_ids.get((namespace, owners)) + names_by_id: dict[str, set[str]] = {} + proposed = [] + for decl, name in declarations: + type_name = _csharp_type_name_for_id(decl, name) + plain = _make_id(parent, type_name) if parent else _make_id(stem, namespace, type_name) + names_by_id.setdefault(plain, set()).add(name) + proposed.append((decl, plain, name, type_name)) + type_groups.update({nid: names for nid, names in names_by_id.items() if len(names) >= 2}) + for decl, plain, name, type_name in proposed: + owner_ids[(namespace, (*owners, type_name))] = _underscore_salted_nid( + plain, name, type_groups, + ) + type_decls.append((decl, plain, name)) def _members(n, type_nid: str) -> None: for c in n.children: @@ -701,7 +715,7 @@ def _java_receiver_type_name(type_node, source: bytes) -> str | None: if t == "type_identifier": name = _read_text(type_node, source) elif t == "scoped_type_identifier": - name = _read_text(type_node, source).rsplit(".", 1)[-1] + name = _read_text(type_node, source) elif t == "generic_type": base = next( ( @@ -772,18 +786,20 @@ def _java_type_chain_leaf( chain: list, receiver_types: dict, bound_names: set[str] | None = None, + field_names: set[str] | None = None, ) -> list | None: """Replace the ["name", x] leaf of a receiver chain with x's declared type (["type", T]); a capitalized name nothing declares is a type itself (`Foo.create().run()`), as for a plain receiver. A ["field", name, owner] is kept only when that owner is such a class name: a local, parameter, or field that shadows it is not the class (`AuditLog Service; Service.INSTANCE`). - None when x is untyped.""" + Fields stay ["field_type", name] so their type is resolved in the declaring + class's context. None when x is an untyped local.""" bound = bound_names or set() if chain[0] == "call": if chain[2] is None: return chain - inner = _java_type_chain_leaf(chain[2], receiver_types, bound) + inner = _java_type_chain_leaf(chain[2], receiver_types, bound, field_names) return ["call", chain[1], inner] if inner else None if chain[0] == "field": owner = chain[2] @@ -794,7 +810,14 @@ def _java_type_chain_leaf( return None return ["field", chain[1], ["type", name]] if chain[0] == "name": - declared = receiver_types.get(chain[1]) + name = chain[1] + if ( + name in (field_names or ()) + or name.startswith("this.") + or (name not in bound and name not in receiver_types and not name[:1].isupper()) + ): + return ["field_type", name.removeprefix("this.")] + declared = receiver_types.get(name) if declared: return ["type", declared] return ["type", chain[1]] if chain[1][:1].isupper() else None @@ -1725,6 +1748,20 @@ def _swift_user_type_name(user_type_node, source: bytes) -> str | None: return text or None return None + +def _swift_receiver_type_name(node, source: bytes) -> str | None: + if node is None: + return None + if node.type == "user_type": + names = [_read_text(child, source) for child in node.children + if child.type == "type_identifier"] + return ".".join(names) or None + if node.type in ("type_annotation", "optional_type", "implicitly_unwrapped_optional_type"): + for child in node.named_children: + if name := _swift_receiver_type_name(child, source): + return name + return None + def _swift_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: """Return Swift generic type-parameter names visible from ``node``. @@ -1899,12 +1936,17 @@ def _swift_constructor_type(call_node, source: bytes) -> str | None: text = _read_text(first, source) if text and text[:1].isupper(): return text + if first is not None and first.type == "navigation_expression": + name = _swift_receiver_name(first, source) + if name and "." in name: + return name return None def _swift_receiver_name(recv_node, source: bytes) -> str | None: """Return the depth-1 receiver name of a Swift member call (``recv.method()``). ``vm.update()`` -> ``vm``; ``Type.staticMethod()`` -> ``Type``; + ``Outer.Builder.method()`` -> ``Outer.Builder``; ``Singleton.shared.method()`` -> ``Singleton`` (head of the chain); ``self.svc.fetch()`` -> ``svc`` (the property the call is reached through). Returns None for anything deeper, so resolution stays depth-1. @@ -1914,6 +1956,19 @@ def _swift_receiver_name(recv_node, source: bytes) -> str | None: if recv_node.type == "simple_identifier": return _read_text(recv_node, source) if recv_node.type == "navigation_expression": + names: list[str] = [] + stack = [recv_node] + while stack: + child = stack.pop() + if child.type == "simple_identifier": + names.append(_read_text(child, source)) + elif child.type in ("navigation_expression", "navigation_suffix"): + stack.extend(reversed(child.named_children)) + else: + break + else: + if names and all(name[:1].isupper() for name in names): + return ".".join(names) head = recv_node.children[0] if recv_node.children else None if head is not None and head.type == "simple_identifier": return _read_text(head, source) @@ -4024,7 +4079,7 @@ def _csharp_receiver_type_name(type_node, source: bytes) -> str | None: info = _read_csharp_type_name(type_node, source) if not info: return None - name = info[0] + name = ".".join((info[2], info[0])) if info[1] and info[2] else info[0] return name or None @@ -5616,7 +5671,7 @@ def _lookup(name): def _ts_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, - walk_fn, decl_node_fn=None) -> bool: + walk_fn, decl_node_fn=None, namespace_stack: list[str] | None = None) -> bool: """Emit enum member nodes, and a container node for a TS `namespace`/`module`. `namespace Foo {}` parses as `internal_module` (with `name`/`body` fields); @@ -5701,9 +5756,18 @@ def _decl(nid, name, kind, label, line): ns_name = ns_name.strip("'\"`") if ns_name: line = node.start_point[0] + 1 - ns_nid = _decl(_make_id(stem, ns_name), ns_name, "ns", ns_name, line) + ns_nid = _decl(_make_id(stem, ".".join(namespace_stack or []), ns_name), + ns_name, "ns", ns_name, line) add_edge_fn(file_nid, ns_nid, "contains", line) - if body is not None: + if namespace_stack is not None and name_node is not None and ns_name: + namespace_stack.append(ns_name) + try: + if body is not None: + for child in body.children: + walk_fn(child, parent_class_nid) + finally: + namespace_stack.pop() + elif body is not None: for child in body.children: walk_fn(child, parent_class_nid) return True @@ -7190,10 +7254,6 @@ def _extract_generic( # extensions don't (file stem is part of the id), so they're collected here # for a corpus-level merge after every file has been parsed. swift_extensions: list[dict] = [] - # C# type id -> its name within the namespace, enclosing types first - # (`` Outer`1.Inner ``). Partial types carry it as `type_path` so the - # corpus-level partial merge can key nested and generic halves exactly. - csharp_type_paths: dict[str, str | None] = {} # #1356: call expressions in property/field initializers (e.g. # `let vm = VM()`) live outside function bodies, so the call-walk never # reaches them. Collect (owner_nid, call_node) here and walk them too. @@ -7224,6 +7284,16 @@ def _extract_generic( java_field_names: dict[str, set[str]] = {} java_return_types: dict[str, str] = {} java_method_scopes: dict[int, tuple[object, str]] = {} + type_paths: dict[str, str] = {} + java_package = "" + java_imports: list[str] = [] + if config.ts_module == "tree_sitter_java": + for child in root.children: + if child.type == "package_declaration": + java_package = _read_text(child, source).removeprefix("package").strip().rstrip(";").strip() + elif child.type == "import_declaration": + imported = _read_text(child, source).removeprefix("import").strip().rstrip(";").strip() + java_imports.append(imported.removeprefix("static ").strip()) # C# receiver typing is method-scoped too (#2299): class fields/properties # are shared, parameters and locals belong only to their declaring method — # the old file-wide table let one method's untypable rebinding poison a @@ -7548,12 +7618,12 @@ def walk(node, parent_class_nid: str | None = None) -> None: # label is untouched; arity 0 keeps today's id exactly. class_name_for_id = _csharp_type_name_for_id(node, class_name) class_nid = _make_id(stem, ".".join(namespace_stack), class_name_for_id) + if parent_class_nid and config.ts_module != "tree_sitter_ruby": + class_nid = _make_id(parent_class_nid, class_name_for_id) if config.ts_module == "tree_sitter_c_sharp": # `Pair` / `_Pair` in one file share an id otherwise (#4415). class_nid = _underscore_salted_nid(class_nid, class_name, csharp_type_groups) - if config.ts_module == "tree_sitter_python" and parent_class_nid: - class_nid = _make_id(parent_class_nid, class_name) - elif config.ts_module == "tree_sitter_python": + if config.ts_module == "tree_sitter_python" and not parent_class_nid: # `Session` / `session()`, `Foo` / `FOO` in one module (#2810). class_nid = _underscore_salted_nid(class_nid, class_name, python_underscore_groups) line = node.start_point[0] + 1 @@ -7562,32 +7632,6 @@ def walk(node, parent_class_nid: str | None = None) -> None: config.ts_module == "tree_sitter_ruby" and class_nid in seen_ids ) if config.ts_module == "tree_sitter_c_sharp": - # The type's name within its namespace, enclosing types first, - # each with its arity (`` Outer`1.Inner ``). The id omits the - # enclosing types, so this is what tells two same-named nested - # types under different outers apart. None when an enclosing - # type's path is unknown. - _type_path: str | None - if parent_class_nid: - _parent_path = csharp_type_paths.get(parent_class_nid) - _type_path = ( - f"{_parent_path}.{class_name_for_id}" - if _parent_path is not None else None - ) - else: - _type_path = class_name_for_id - if class_nid not in csharp_type_paths: - csharp_type_paths[class_nid] = _type_path - elif csharp_type_paths[class_nid] != _type_path: - # Two declarations in this file share one id but not one - # path (same-named nested types under different outers). - # No path is right for both, so the node gets none and the - # partial merge leaves it alone. - csharp_type_paths[class_nid] = None - _type_path = None - for _prev in nodes: - if _prev.get("id") == class_nid and isinstance(_prev.get("metadata"), dict): - _prev["metadata"].pop("type_path", None) if parent_class_nid: metadata = {"is_nested_type": True} # #2332: `partial class Foo` split across files mints one node @@ -7606,8 +7650,6 @@ def walk(node, parent_class_nid: str | None = None) -> None: ): partial_metadata: dict[str, object] = dict(metadata or {}) partial_metadata["is_partial"] = True - if _type_path is not None: - partial_metadata["type_path"] = _type_path metadata = partial_metadata if config.ts_module == "tree_sitter_ruby": ruby_body = _find_body(node, config) @@ -7638,6 +7680,20 @@ def walk(node, parent_class_nid: str | None = None) -> None: metadata["python_opaque_bases"] = True class_nid = decl_node(class_nid, class_name, "type", class_name, line, metadata=metadata) + parent_path = type_paths.get(parent_class_nid or "") + type_path = ( + f"{parent_path}.{class_name_for_id}" + if parent_path and not ruby_segments else class_name_for_id + ) + type_paths[class_nid] = type_path + class_node = next(n for n in nodes if n["id"] == class_nid) + class_node["metadata"] = sanitize_metadata({ + **class_node.get("metadata", {}), "type_path": type_path, + }) + if config.ts_module == "tree_sitter_java": + class_node["_java_type"] = { + "path": type_path, "package": java_package, "imports": java_imports, + } if config.ts_module == "tree_sitter_ruby" and metadata: # Reopened declarations collapse onto the same file-local id; # merge fail-closed markers that add_node intentionally skips. @@ -8868,6 +8924,7 @@ def scala_base_name(type_node) -> str | None: prop_type: str | None = None type_anno = _swift_property_type_node(node) if type_anno is not None: + prop_type = _swift_receiver_type_name(type_anno, source) refs: list[tuple[str, str]] = [] _swift_collect_type_refs(type_anno, source, False, refs) for ref_name, role in refs: @@ -9493,7 +9550,7 @@ def scala_base_name(type_node) -> str | None: type_node = p.child_by_field_name("type") refs: list[tuple[str, str]] = [] _swift_collect_type_refs(type_node, source, False, refs) - param_type: str | None = None + param_type = _swift_receiver_type_name(type_node, source) for ref_name, role in refs: ctx = "generic_arg" if role == "generic_arg" else "parameter_type" target_nid = ensure_named_node(ref_name, line) @@ -9856,7 +9913,7 @@ def collect(node, source: bytes, generic: bool, if _ts_extra_walk(node, source, file_nid, stem, str_path, nodes, edges, seen_ids, function_bodies, parent_class_nid, add_node, add_edge, walk, - decl_node_fn=decl_node): + decl_node_fn=decl_node, namespace_stack=namespace_stack): return if config.ts_module == "tree_sitter_c_sharp": @@ -10238,13 +10295,17 @@ def _fields_up_chain(tables: dict, class_nid) -> dict: # Names a Java receiver can be without being an inherited field. Keyed by # caller node: overloads share one node, so their names are pooled. java_bound_names: dict[str, set[str]] = {} + java_local_names_by_body = { + body_id: _java_bound_names(method_node, source) + for body_id, (method_node, _) in java_method_scopes.items() + } _java_body_caller = {id(body): nid for nid, body in function_bodies} _java_field_name_tables = {c: dict.fromkeys(names, "") for c, names in java_field_names.items()} for body_id, (method_node, class_nid) in java_method_scopes.items(): caller = _java_body_caller.get(body_id) if caller: java_bound_names.setdefault(caller, set()).update( - _java_bound_names(method_node, source), + java_local_names_by_body[body_id], _fields_up_chain(_java_field_name_tables, class_nid), ) csharp_receiver_types = { @@ -11174,6 +11235,10 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) + _swift_defer = ( + config.ts_module == "tree_sitter_swift" + and is_member_call and not self_receiver + ) # Lua: a colon call that was NOT rewritten to a table-qualified name # above (`obj:m()` on an unknown receiver) must not fall back to the # bare-name map, or `m` would bind to any unrelated top-level @@ -11237,7 +11302,7 @@ def walk_calls( or (cpp_binding is None and member_receiver in cpp_unknown_fields.get( method_owner.get(caller_nid, ""), ()))) ) - if _python_defer or _java_defer or _csharp_defer or _js_member_defer or _cpp_member_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or is_scoped_call or ( + if _python_defer or _java_defer or _csharp_defer or _swift_defer or _js_member_defer or _cpp_member_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or is_scoped_call or ( is_member_call and member_receiver and ( @@ -11514,6 +11579,11 @@ def walk_calls( rc_entry["receiver_undeclared"] = True if config.ts_module == "tree_sitter_java": rc_entry["lang"] = "java" + field_names = set(_fields_up_chain( + _java_field_name_tables, method_owner.get(caller_nid, caller_nid), + )) - extra_locals + if member_receiver and (is_this_field_call or member_receiver in field_names): + rc_entry["receiver_field"] = member_receiver.removeprefix("this.") receiver_type = (receiver_types or {}).get(member_receiver or "") if receiver_type: rc_entry["receiver_type"] = receiver_type @@ -11529,6 +11599,7 @@ def walk_calls( java_receiver_chain, receiver_types if isinstance(receiver_types, dict) else {}, java_bound_names.get(caller_nid), + field_names, ) if chain: rc_entry["java_receiver_chain"] = chain @@ -11908,7 +11979,8 @@ def _fold(table: dict[str, str]) -> None: body_node, caller_nid, receiver_types_by_body.get(id(body_node)), - frozenset(closure_locals_by_body.get(id(body_node), ())), + frozenset(closure_locals_by_body.get(id(body_node), ())) + | frozenset(java_local_names_by_body.get(id(body_node), ())), ) # #1356: walk property/field initializers (collected above). walk_calls @@ -12035,7 +12107,9 @@ def _scan_js_module_dispatch(n) -> None: # id-keyed ObjC table went stale (#3150). _field_table_export = [ {"lang": _lang, "class_label": _n.get("label"), - "source_file": _n.get("source_file"), "fields": dict(_tbl)} + "source_file": _n.get("source_file"), "fields": dict(_tbl), + "type_path": type_paths.get(_cls), + "namespace": (_n.get("metadata") or {}).get("namespace", "")} for _lang, _tables in ( ("java", java_field_types), ("java_static", java_static_field_types), @@ -12073,6 +12147,13 @@ def _scan_js_module_dispatch(n) -> None: _pkg = _kotlin_package_name(root, source) if _pkg: result["kotlin_package"] = _pkg + for n in nodes: + if n["id"] in callable_def_nids: + n["metadata"] = sanitize_metadata({ + **(n.get("metadata") or {}), "namespace": _pkg, + }) + for entry in result.get("member_field_tables", []): + entry["namespace"] = _pkg if callable_def_nids: # Mark function / method / class defs with a `_callable` attribute so the # cross-file indirect_call pass can resolve a by-name callback only to a real diff --git a/graphify/watch.py b/graphify/watch.py index b4e1695ca1..af0b64e014 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -2756,7 +2756,7 @@ def _add_deleted_source(path: Path) -> None: "_cpp_member_records", "_cpp_lookup", "_cpp_field_positions", "_cpp_type_bindings", "_cpp_include_positions", "_cpp_include_paths", "_rust_impl_key", "_rust_declaration_count", - "_ts_public_twin", "_py_property", "_java_ret", + "_ts_public_twin", "_py_property", "_java_ret", "_java_type", "_ts_fields", "_ts_static_fields", "_py_fixture", "_py_return_type", "_go_fields", "_go_package_path", @@ -2775,6 +2775,11 @@ def _add_deleted_source(path: Path) -> None: "cpp_conditions", "ruby_reopened", "ruby_external_method_owners", + "type_path", + "namespace", + "scope_chain", + "is_nested_type", + "is_partial", # Erlang remote-call resolution keys (#3714): an # unchanged callee module must keep its # module/name/arity so `foo:bar()` still resolves diff --git a/tests/test_csharp_extension_keyword_types.py b/tests/test_csharp_extension_keyword_types.py index d40fa8a3f8..08c0a5c0ee 100644 --- a/tests/test_csharp_extension_keyword_types.py +++ b/tests/test_csharp_extension_keyword_types.py @@ -11,6 +11,8 @@ import os from pathlib import Path +import pytest + from graphify.extract import extract @@ -69,12 +71,19 @@ def test_keyword_receiver_binds_system_type_extension(tmp_path): assert _call_targets(r, ".UseField()") == {"BoolExt.ToVis()"} -def test_system_type_receiver_binds_keyword_extension(tmp_path): +@pytest.mark.parametrize("type_name", ["System.Char", "global::System.Char"]) +def test_system_type_receiver_binds_keyword_extension(tmp_path, type_name): r = _extract(tmp_path, _host( - " public void Use(System.Char c) { c.IsWs(); }\n")) + f" public void Use({type_name} c) {{ c.IsWs(); }}\n")) assert _call_targets(r, ".Use()") == {"CharExt.IsWs()"} +def test_system_alias_does_not_bind_a_keyword_extension(tmp_path): + files = _host(" public void Use(System.Char c) { c.IsWs(); }\n") + files["Host.cs"] = "using System = Missing;\n" + files["Host.cs"] + assert _call_targets(_extract(tmp_path, files), ".Use()") == set() + + def test_nullable_value_of_keyword_type_binds_extension(tmp_path): r = _extract(tmp_path, _host( " private char? _ch;\n" diff --git a/tests/test_csharp_overloads.py b/tests/test_csharp_overloads.py index 2392d83d4f..21ebf9df38 100644 --- a/tests/test_csharp_overloads.py +++ b/tests/test_csharp_overloads.py @@ -266,4 +266,4 @@ def test_overloads_in_struct_record_and_beside_other_members(tmp_path): "public record R { public void N() { } class Inner { public void N(int x) { } } public void N(int x) { } }\n" )}) assert len([n for n in r["nodes"] if n["id"].startswith("l_st_m") and n["label"] == ".M()"]) == 2 - assert {n["id"] for n in r["nodes"] if n["label"] == ".N()"} == {"l_r_n", "l_r_n_46f8ab", "l_inner_n"} + assert {n["id"] for n in r["nodes"] if n["label"] == ".N()"} == {"l_r_n", "l_r_n_46f8ab", "l_r_inner_n"} diff --git a/tests/test_java_nested_type_identity.py b/tests/test_java_nested_type_identity.py new file mode 100644 index 0000000000..703c624cfc --- /dev/null +++ b/tests/test_java_nested_type_identity.py @@ -0,0 +1,389 @@ +"""Qualified Java receivers keep distinct nested owners (#5185).""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from graphify.extract import extract + + +def _extract(root: Path, sources: dict[str, str], *, reverse: bool = False) -> dict: + paths = [] + for name, body in sources.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + if reverse: + paths.reverse() + result = extract(paths, root=root, cache_root=root / "cache", parallel=False) + assert not result["failed_sources"] + assert not result["skipped_sources"] + return result + + +def _targets(result: dict, caller: str = ".run()") -> list[dict]: + nodes = {node["id"]: node for node in result["nodes"]} + return [ + nodes[edge["target"]] for edge in result["edges"] + if edge["relation"] == "calls" + and nodes[edge["source"]]["label"] == caller + and nodes[edge["target"]]["label"] == ".setEnabled()" + ] + + +def _type(name: str) -> str: + return ( + f"class {name} {{ public static class Builder {{ " + "public Builder size(int n) { return this; } " + "public Builder setEnabled(boolean x) { return this; } } }" + ) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_nested_declarations_keep_owners_in_one_file(tmp_path: Path, reverse: bool): + declarations = [_type("Other"), _type("Item")] + if reverse: + declarations.reverse() + source = "\n".join([ + *declarations, + "class Loader { void run() { new Item.Builder().setEnabled(true); } }", + ]) + result = _extract(tmp_path, {"Types.java": source}) + setters = [node for node in result["nodes"] if node["label"] == ".setEnabled()"] + assert len(setters) == 2 + expected_line = 1 if reverse else 2 + assert [(node["source_location"]) for node in _targets(result)] == [f"L{expected_line}"] + assert len({node["id"] for node in setters}) == 2 + + +@pytest.mark.parametrize("expression", [ + "Item.Builder b = new Item.Builder(); b.setEnabled(true);", + "new Item.Builder().size(1).setEnabled(true);", + "((Item.Builder)new Item.Builder()).setEnabled(true);", + "Factory.create().size(1).setEnabled(true);", +]) +def test_qualified_nested_receiver_with_competing_type(tmp_path: Path, expression: str): + sources = { + "Item.java": _type("Item"), + "Other.java": _type("Other"), + "Factory.java": "class Factory { static Item.Builder create() { return new Item.Builder(); } }", + "Loader.java": f"class Loader {{ void run() {{ {expression} }} }}", + } + for reverse in (False, True): + result = _extract(tmp_path, sources, reverse=reverse) + assert [node["source_file"] for node in _targets(result)] == ["Item.java"] + + +@pytest.mark.parametrize("import_statement, receiver", [ + ("", "alpha.Item.Builder"), + ("import alpha.Item;", "Item.Builder"), + ("import alpha.Item.Builder;", "Builder"), +]) +def test_nested_receiver_uses_package_and_explicit_import( + tmp_path: Path, import_statement: str, receiver: str, +): + sources = { + "alpha/Item.java": "package alpha; " + _type("Item").replace("class Item", "public class Item"), + "beta/Item.java": "package beta; " + _type("Item").replace("class Item", "public class Item"), + "Loader.java": ( + f"{import_statement} class Loader {{ void run() {{ " + f"new {receiver}().size(1).setEnabled(true); }} }}" + ), + } + result = _extract(tmp_path, sources) + assert len(_targets(result)) == 1 + assert _targets(result)[0]["id"].startswith("alpha_") + + +@pytest.mark.parametrize("import_statement, receiver", [ + ("", "alpha.Builder"), + ("import alpha.Builder;", "Builder"), +]) +def test_top_level_package_receivers_do_not_lose_qualification( + tmp_path: Path, import_statement: str, receiver: str, +): + sources = { + f"{package}/Builder.java": ( + f"package {package}; public class Builder {{ " + "public void setEnabled(boolean x) {} }" + ) for package in ("alpha", "beta") + } + sources["Loader.java"] = ( + f"{import_statement} class Loader {{ void run() {{ new {receiver}().setEnabled(true); }} }}" + ) + result = _extract(tmp_path, sources) + assert len(_targets(result)) == 1 + assert _targets(result)[0]["id"].startswith("alpha_") + + +def test_unknown_qualified_type_does_not_bind_same_named_local(tmp_path: Path): + result = _extract(tmp_path, { + "Item.java": _type("Item"), + "Loader.java": ( + "import external.Item; class Loader { void run() { " + "new Item.Builder().setEnabled(true); } }" + ), + }) + assert not _targets(result) + + +def test_unqualified_ambiguous_receiver_stays_unlinked(tmp_path: Path): + result = _extract(tmp_path, { + "Item.java": _type("Item"), + "Other.java": _type("Other"), + "Loader.java": "class Loader { void run(Builder b) { b.setEnabled(true); } }", + }) + assert not _targets(result) + + +@pytest.mark.parametrize("receiver", ["Item.Group.Builder", "Item.Builder"]) +def test_deep_and_generic_nested_receivers(tmp_path: Path, receiver: str): + if "Group" in receiver: + body = "static class Group { public static class Builder { public void setEnabled(boolean x) {} } }" + else: + body = "public static class Builder { public void setEnabled(boolean x) {} }" + result = _extract(tmp_path, { + "Item.java": f"class Item {{ {body} }}", + "Other.java": f"class Other {{ {body} }}", + "Loader.java": f"class Loader {{ void run() {{ new {receiver}().setEnabled(true); }} }}", + }) + assert [node["source_file"] for node in _targets(result)] == ["Item.java"] + + +def test_nested_field_tables_bind_the_correct_owner(tmp_path: Path): + result = _extract(tmp_path, { + "Types.java": ( + "class Item { static class Builder { Target value; void run() { value.setEnabled(true); } } }\n" + "class Other { static class Builder { Decoy value; } }\n" + "class Target { void setEnabled(boolean x) {} }\n" + "class Decoy { void setEnabled(boolean x) {} }\n" + ), + }) + assert len(_targets(result)) == 1 + assert "_target_setenabled" in _targets(result)[0]["id"] + + +def test_qualified_calls_match_full_build_after_incremental_addition(tmp_path: Path): + from graphify.watch import _rebuild_code + + sources = { + "Item.java": _type("Item"), + "Loader.java": ( + "class Loader { void run() { new Item.Builder().size(1).setEnabled(true); } }" + ), + } + inc, full = tmp_path / "inc", tmp_path / "full" + for root in (inc, full): + root.mkdir() + for name, body in sources.items(): + (root / name).write_text(body, encoding="utf-8") + assert _rebuild_code(inc, no_cluster=True, acquire_lock=False, force=True) + for root in (inc, full): + (root / "Other.java").write_text(_type("Other"), encoding="utf-8") + assert _rebuild_code( + inc, changed_paths=[inc / "Other.java"], no_cluster=True, acquire_lock=False, + ) + assert _rebuild_code(full, no_cluster=True, acquire_lock=False, force=True) + + def targets(root: Path) -> list[str]: + graph = json.loads((root / "graphify-out" / "graph.json").read_text(encoding="utf-8")) + graph["edges"] = graph.get("links", graph.get("edges", [])) + return [node["id"] for node in _targets(graph)] + + assert targets(inc) == targets(full) + assert len(targets(full)) == 1 + caller = inc / "Loader.java" + caller.write_text(caller.read_text(encoding="utf-8") + "\n// changed\n", encoding="utf-8") + assert _rebuild_code( + inc, changed_paths=[caller], no_cluster=True, acquire_lock=False, + ) + assert targets(inc) == targets(full) + + +def test_cached_nested_identities_survive_checkout_relocation(tmp_path: Path): + import shutil + + sources = { + "Item.java": _type("Item"), + "Other.java": _type("Other"), + "Loader.java": ( + "class Loader { void run() { Item.Builder b = new Item.Builder(); b.setEnabled(true); } }" + ), + } + original = tmp_path / "original" + first = _extract(original, sources) + relocated = tmp_path / "relocated" + shutil.copytree(original, relocated) + warm = _extract(relocated, sources, reverse=True) + assert len(_targets(first)) == len(_targets(warm)) == 1 + assert _targets(first)[0]["id"] == _targets(warm)[0]["id"] + + +def test_java_cache_schema_retires_flat_nested_identities(tmp_path: Path, monkeypatch): + from graphify import cache + from graphify.extract import extract_java + + path = tmp_path / "Types.java" + path.write_text(_type("Other") + "\n" + _type("Item"), encoding="utf-8") + schema = cache._AST_CACHE_SCHEMA + monkeypatch.setattr(cache, "_AST_CACHE_SCHEMA", schema - 1) + stale = extract_java(path) + for node in stale["nodes"]: + node.pop("_java_type", None) + cache.save_cached(path, stale, root=tmp_path, cache_root=tmp_path / "cache", kind="ast") + monkeypatch.setattr(cache, "_AST_CACHE_SCHEMA", schema) + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + builders = [node for node in result["nodes"] if node["label"] == "Builder"] + assert len(builders) == 2 + assert {node["_java_type"]["path"] for node in builders} == {"Item.Builder", "Other.Builder"} + + +@pytest.mark.parametrize("body", [ + "void run(Builder b) { b.setEnabled(true); }", + "void run() { new Builder().setEnabled(true); }", +]) +def test_inherited_nested_type_resolves_through_imported_superclass(tmp_path: Path, body: str): + result = _extract(tmp_path, { + "lib/Parent.java": "package lib; public class Parent { public static class Builder { " + "public void setEnabled(boolean v) {} } }", + "lib/Other.java": "package lib; public class Other { public static class Builder { " + "public void setEnabled(boolean v) {} } }", + "app/Child.java": f"package app; import lib.Parent; class Child extends Parent {{ {body} }}", + }) + assert [n["source_file"] for n in _targets(result)] == ["lib/Parent.java"] + + +def test_ambiguous_inherited_nested_types_stay_unlinked(tmp_path: Path): + result = _extract(tmp_path, { + "lib/First.java": "package lib; public interface First { class Builder { " + "public void setEnabled(boolean v) {} } }", + "lib/Second.java": "package lib; public interface Second { class Builder { " + "public void setEnabled(boolean v) {} } }", + "app/Child.java": "package app; import lib.First; import lib.Second; " + "class Child implements First, Second { " + "void run(Builder b) { b.setEnabled(true); } }", + }) + assert not _targets(result) + + +@pytest.mark.parametrize("bases, expected_sources", [ + ("extends Parent", ["Parent.java"]), + ("", []), + ("implements First, Second", []), +]) +def test_qualified_nested_type_binds_lexical_prefix( + tmp_path: Path, bases: str, expected_sources: list[str], +): + sources = { + "Parent.java": _type("Parent"), + "Item.java": _type("Item"), + "First.java": _type("First").replace("class First", "interface First"), + "Second.java": _type("Second").replace("class Second", "interface Second"), + "Caller.java": ( + f"class Caller {{ static class Item {bases} {{}} " + "void run(Item.Builder b) { b.setEnabled(true); } }" + ), + } + for reverse in (False, True): + result = _extract(tmp_path, sources, reverse=reverse) + assert [node["source_file"] for node in _targets(result)] == expected_sources + + +@pytest.mark.parametrize("field, expression", [ + ("protected Builder builder;", "builder.setEnabled(true);"), + ("protected Builder builder;", "this.builder.setEnabled(true);"), + ("public static Builder BUILDER;", "Child.BUILDER.setEnabled(true);"), + ("public static Builder BUILDER;", "Child.BUILDER.size(1).setEnabled(true);"), +]) +def test_inherited_field_type_uses_declaring_import( + tmp_path: Path, field: str, expression: str, +): + sources = { + "lib/Types.java": "package lib; " + _type("Types").replace("class Types", "public class Types"), + "base/Parent.java": ( + f"package base; import lib.Types.Builder; public class Parent {{ {field} }}" + ), + "app/Child.java": ( + "package app; import base.Parent; class Child extends Parent { " + f"void run() {{ {expression} }} }}" + ), + } + for reverse in (False, True): + result = _extract(tmp_path, sources, reverse=reverse) + assert [node["source_file"] for node in _targets(result)] == ["lib/Types.java"] + + +@pytest.mark.parametrize("field, expression", [ + ("protected Builder value;", "value.setEnabled(true);"), + ("protected Builder value;", "this.value.setEnabled(true);"), + ("protected Builder value;", "value.size(1).setEnabled(true);"), + ("protected Builder value;", "this.value.size(1).setEnabled(true);"), + ("public static Builder VALUE;", "Child.VALUE.setEnabled(true);"), +]) +@pytest.mark.parametrize("same_file", [False, True]) +def test_inherited_field_type_ignores_subclass_type_shadow( + tmp_path: Path, field: str, expression: str, same_file: bool, +): + sources = { + "Parent.java": _type("Parent")[:-1] + field + "}", + "Child.java": ( + _type("Child").replace("class Child", "class Child extends Parent")[:-1] + + f"void run() {{ {expression} }} }}" + ), + } + if same_file: + sources = {"Types.java": "\n".join(sources.values())} + for reverse in (False, True): + result = _extract(tmp_path, sources, reverse=reverse) + nodes = {node["id"]: node for node in result["nodes"]} + targets = {node["id"] for node in _targets(result)} + owners = [ + nodes[edge["source"]]["_java_type"]["path"] for edge in result["edges"] + if edge["relation"] == "method" and edge["target"] in targets + ] + assert owners == ["Parent.Builder"] + + +@pytest.mark.parametrize("body", [ + "void run(Builder value) { value.setEnabled(true); }", + "void run() { Builder value = new Builder(); value.setEnabled(true); }", +]) +def test_local_receiver_keeps_its_context_when_it_shadows_inherited_field( + tmp_path: Path, body: str, +): + source = ( + _type("Parent")[:-1] + "protected Builder value; }\n" + + _type("Child").replace("class Child", "class Child extends Parent")[:-1] + + body + "}" + ) + result = _extract(tmp_path, {"Types.java": source}) + nodes = {node["id"]: node for node in result["nodes"]} + targets = {node["id"] for node in _targets(result)} + owners = [ + nodes[edge["source"]]["_java_type"]["path"] for edge in result["edges"] + if edge["relation"] == "method" and edge["target"] in targets + ] + assert owners == ["Child.Builder"] + + +@pytest.mark.parametrize("other_overload", [ + "void run(Service Service) {}", + "void run(int n) { Service Service = new Service(); }", +]) +@pytest.mark.parametrize("reverse_declarations", [False, True]) +def test_overload_bindings_do_not_become_field_receivers( + tmp_path: Path, other_overload: str, reverse_declarations: bool, +): + methods = [other_overload, "void run() { Service.setEnabled(true); }"] + if reverse_declarations: + methods.reverse() + sources = { + "Service.java": "class Service { static void setEnabled(boolean v) {} }", + "Caller.java": "class Caller { " + " ".join(methods) + " }", + } + for reverse in (False, True): + result = _extract(tmp_path, sources, reverse=reverse) + assert [node["source_file"] for node in _targets(result)] == ["Service.java"] diff --git a/tests/test_kotlin_java_call_edges_4935.py b/tests/test_kotlin_java_call_edges_4935.py index 01d8a7b179..06b88e0bd1 100644 --- a/tests/test_kotlin_java_call_edges_4935.py +++ b/tests/test_kotlin_java_call_edges_4935.py @@ -17,6 +17,8 @@ from pathlib import Path +import pytest + from graphify.extract import extract LOGGER_JAVA = """\ @@ -81,14 +83,31 @@ def test_a_property_initializer_call_still_resolves(tmp_path: Path) -> None: assert ("Holder:Holder", "Logger:.single()") in edges -def test_two_classes_with_the_same_name_stay_ambiguous(tmp_path: Path) -> None: - edges = _calls(tmp_path, { +@pytest.mark.parametrize("imported", [False, True]) +def test_same_named_classes_need_explicit_import_to_disambiguate(tmp_path: Path, imported: bool) -> None: + files = { "a/Dup.java": "package a;\npublic class Dup { public static void ping() {} }\n", "b/Dup.java": "package b;\npublic class Dup { public static void ping() {} }\n", - "app/Caller.kt": "package app\nimport a.Dup\n" - "class Caller { fun go() { Dup.ping() } }\n", - }) - assert not {e for e in edges if e[0].startswith("Caller")} + "app/Caller.kt": "package app\n" + ("import a.Dup\n" if imported else "") + + "class Caller { fun go() { Dup.ping() } }\n", + } + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + nodes = {n["id"]: n for n in result["nodes"]} + targets = { + e["target"] for e in result["edges"] + if e["relation"] == "calls" and nodes[e["source"]]["label"] == ".go()" + } + expected = { + n["id"] for n in result["nodes"] + if n["label"] == ".ping()" and n["source_file"] == "a/Dup.java" + } if imported else set() + assert targets == expected def test_inherited_in_repo_members_still_resolve(tmp_path: Path) -> None: diff --git a/tests/test_nested_type_owners.py b/tests/test_nested_type_owners.py new file mode 100644 index 0000000000..1f95af0b5f --- /dev/null +++ b/tests/test_nested_type_owners.py @@ -0,0 +1,73 @@ +"""Distinct enclosing owners must not collapse same-named declarations (#5185).""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from graphify.extract import extract + + +CASES = [ + ("java", "class OWNER {\n static class Builder {\n void setEnabled(boolean v) {}\n }\n}\n"), + ("cs", "class OWNER {\n public class Builder {\n public void setEnabled(bool v) {}\n }\n}\n"), + ("cpp", "struct OWNER {\n struct Builder {\n void setEnabled(bool v) {}\n };\n};\n"), + ("kt", "class OWNER {\n class Builder {\n fun setEnabled(v: Boolean) {}\n }\n}\n"), + ("scala", "class OWNER {\n class Builder {\n def setEnabled(v: Boolean): Unit = {}\n }\n}\n"), + ("swift", "class OWNER {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n}\n"), + ("ts", "namespace OWNER {\n export class Builder {\n setEnabled(v: boolean): void {}\n }\n}\n"), +] + + +@pytest.mark.parametrize("suffix,template", CASES, ids=[case[0] for case in CASES]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_same_named_nested_types_preserve_both_owners( + tmp_path: Path, suffix: str, template: str, reverse: bool, +): + owners = ["Left", "Right"] + if reverse: + owners.reverse() + path = tmp_path / f"Types.{suffix}" + path.write_text("".join(template.replace("OWNER", owner) for owner in owners), encoding="utf-8") + previous_ids = None + for _ in range(2): + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + assert not result["failed_sources"] + assert not result["skipped_sources"] + builders = [n for n in result["nodes"] if n.get("source_file") and n["label"] == "Builder"] + methods = [n for n in result["nodes"] if n.get("source_file") and n["label"] == ".setEnabled()"] + assert len({n["id"] for n in builders}) == 2 + assert len({n["id"] for n in methods}) == 2 + assert len({n["source_location"] for n in builders}) == 2 + assert len({n["source_location"] for n in methods}) == 2 + member_edges = { + (e["source"], e["target"]) for e in result["edges"] + if e["relation"] == "method" and e["target"] in {n["id"] for n in methods} + } + assert {source for source, _ in member_edges} == {n["id"] for n in builders} + assert len(member_edges) == 2 + if suffix != "ts": + outer_ids = {n["id"] for n in result["nodes"] if n["label"] in owners} + ownership = { + (e["source"], e["target"]) for e in result["edges"] + if e["relation"] == "contains" and e["source"] in outer_ids + and e["target"] in {n["id"] for n in builders} + } + assert len(ownership) == 2 + assert len({target for _, target in ownership}) == 2 + ids = {n["id"] for n in builders + methods} + if previous_ids is not None: + assert ids == previous_ids + previous_ids = ids + + +def test_nested_csharp_case_and_underscore_members_remain_distinct(tmp_path: Path): + path = tmp_path / "Types.cs" + path.write_text( + "class Outer { class Builder { void Put() {} void _Put() {} } }\n", + encoding="utf-8", + ) + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + members = [n for n in result["nodes"] if n["label"] in {".Put()", "._Put()"}] + assert len({n["id"] for n in members}) == 2 + assert {n["label"] for n in members} == {".Put()", "._Put()"} diff --git a/tests/test_qualified_type_competition.py b/tests/test_qualified_type_competition.py new file mode 100644 index 0000000000..c6c7e856ae --- /dev/null +++ b/tests/test_qualified_type_competition.py @@ -0,0 +1,321 @@ +"""Adding an unrelated type must not erase a uniquely qualified call (#5185).""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from graphify.extract import extract + + +CASES = [ + ("csharp-nested", "cs", + "public class Left { public class Builder { public void setEnabled(bool v) {} } }\n", + "public class Caller { public void run(Left.Builder b) { b.setEnabled(true); } }\n", + "public class Right { public class Builder { public void setEnabled(bool v) {} } }\n"), + ("csharp-namespace", "cs", + "namespace Left { public class Builder { public void setEnabled(bool v) {} } }\n", + "public class Caller { public void run(Left.Builder b) { b.setEnabled(true); } }\n", + "namespace Right { public class Builder { public void setEnabled(bool v) {} } }\n"), + ("csharp-global-nested", "cs", + "public class Left { public class Builder { public void setEnabled(bool v) {} } }\n", + "public class Caller { public void run(global::Left.Builder b) { b.setEnabled(true); } }\n", + "public class Right { public class Builder { public void setEnabled(bool v) {} } }\n"), + ("csharp-inherited-nested", "cs", + "public class Parent { public class Builder { public void setEnabled(bool v) {} } }\n" + "public class Child : Parent {}\n", + "public class Caller { public void run(Child.Builder b) { b.setEnabled(true); } }\n", + "public class Right { public class Builder { public void setEnabled(bool v) {} } }\n"), + ("csharp-global-inherited-nested", "cs", + "public class Parent { public class Builder { public void setEnabled(bool v) {} } }\n" + "public class Child : Parent {}\n", + "public class Caller { public void run(global::Child.Builder b) { b.setEnabled(true); } }\n", + "public class Right { public class Builder { public void setEnabled(bool v) {} } }\n"), + ("csharp-using-control", "cs", + "namespace Left { public class Builder { public void setEnabled(bool v) {} } }\n", + "using Left;\npublic class Caller { public void run(Builder b) { b.setEnabled(true); } }\n", + "namespace Right { public class Builder { public void setEnabled(bool v) {} } }\n"), + ("kotlin-imported-object", "kt", + "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", + "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"), + ("swift-qualified-static", "swift", + "class Left {\n class Builder {\n static func setEnabled(_ v: Bool) {}\n }\n}\n", + "class Caller {\n func run() { Left.Builder.setEnabled(true) }\n}\n", + "class Right {\n class Builder {\n static func setEnabled(_ v: Bool) {}\n }\n}\n"), + ("swift-qualified-parameter", "swift", + "class Left {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n}\n", + "class Caller {\n func run(_ b: Left.Builder) { b.setEnabled(true) }\n}\n", + "class Right {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n}\n"), + ("swift-qualified-constructor", "swift", + "class Left {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n}\n", + "class Caller {\n func run() {\n let b = Left.Builder()\n b.setEnabled(true)\n }\n}\n", + "class Right {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n}\n"), + ("swift-deep-qualified-constructor", "swift", + "class Left {\n class Group {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n }\n}\n", + "class Caller {\n func run() {\n let b = Left.Group.Builder()\n b.setEnabled(true)\n }\n}\n", + "class Right {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n}\n"), + ("cpp-qualified-control", "hpp", + "struct Left { struct Builder { void setEnabled(bool v) {} }; };\n", + '#include "Left.hpp"\nvoid run(Left::Builder& b) { b.setEnabled(true); }\n', + "struct Right { struct Builder { void setEnabled(bool v) {} }; };\n"), + ("typescript-imported-control", "ts", + "export class Builder { setEnabled(v: boolean): void {} }\n", + 'import { Builder } from "./Left";\nexport function run(b: Builder): void { b.setEnabled(true); }\n', + "export class Builder { setEnabled(v: boolean): void {} }\n"), +] + + +@pytest.mark.parametrize("name,suffix,target,caller,competitor", CASES, ids=[case[0] for case in CASES]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_competing_type_preserves_explicit_receiver( + tmp_path: Path, name: str, suffix: str, target: str, + caller: str, competitor: str, reverse: bool, +): + left = tmp_path / f"Left.{suffix}" + use = tmp_path / ("Caller.cpp" if suffix == "hpp" else f"Caller.{suffix}") + right = tmp_path / f"Right.{suffix}" + left.write_text(target, encoding="utf-8") + use.write_text(caller, encoding="utf-8") + selected_id = None + for added in (False, True): + paths = [left, use] + if added: + right.write_text(competitor, encoding="utf-8") + paths.append(right) + if reverse: + paths.reverse() + for _ in range(2): + result = extract(paths, root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + assert not result["failed_sources"] + assert not result["skipped_sources"] + nodes = {n["id"]: n for n in result["nodes"]} + methods = [n for n in nodes.values() if n.get("source_file") and n["label"] == ".setEnabled()"] + assert len(methods) == 1 + added + selected = [n["id"] for n in methods if Path(n["source_file"]).name == left.name] + assert len(selected) == 1 + if selected_id is None: + selected_id = selected[0] + assert selected == [selected_id] + targets = { + e["target"] for e in result["edges"] + if e["relation"] == "calls" + and nodes[e["source"]]["label"].lstrip(".") == "run()" + and Path(nodes[e["source"]].get("source_file", "")).name == use.name + and e["target"] in {n["id"] for n in methods} + } + assert targets == {selected_id}, name + + +@pytest.mark.parametrize("suffix,target,caller", [ + ("cs", + "public class Builder { public void setEnabled(bool v) {} }\n", + "class Caller { void run(Missing.Builder b) { b.setEnabled(true); } }\n"), + ("cs", + "namespace Local { public class Left { public class Builder { public void setEnabled(bool v) {} } } }\n", + "namespace Local { class Caller { void run(global::Left.Builder b) { b.setEnabled(true); } } }\n"), + ("kt", + "package left\nobject Builder {\n fun setEnabled(v: Boolean) {}\n}\n", + "import missing.Builder\nclass Caller {\n fun run() { Builder.setEnabled(true) }\n}\n"), +]) +def test_absent_explicit_type_never_falls_back_to_bare_name( + tmp_path: Path, suffix: str, target: str, caller: str, +): + paths = [tmp_path / f"Left.{suffix}", tmp_path / f"Caller.{suffix}"] + for path, body in zip(paths, (target, caller)): + path.write_text(body, encoding="utf-8") + result = extract(paths, root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + nodes = {n["id"]: n for n in result["nodes"]} + assert not [ + e for e in result["edges"] if e["relation"] == "calls" + and nodes[e["source"]]["label"] == ".run()" + and nodes[e["target"]]["label"] == ".setEnabled()" + ] + + +def test_swift_nested_lexical_type_beats_unrelated_top_level_type(tmp_path: Path): + path = tmp_path / "Types.swift" + path.write_text( + "class Outer {\n" + " class Builder {\n static func setEnabled(_ v: Bool) {}\n }\n" + " func run() { Builder.setEnabled(true) }\n" + "}\n" + "class Builder {\n static func setEnabled(_ v: Bool) {}\n}\n", + encoding="utf-8", + ) + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + nodes = {n["id"]: n for n in result["nodes"]} + targets = [ + nodes[e["target"]] for e in result["edges"] if e["relation"] == "calls" + and nodes[e["source"]]["label"] == ".run()" + and nodes[e["target"]]["label"] == ".setEnabled()" + ] + assert len(targets) == 1 + assert targets[0]["source_location"] == "L3" + + +@pytest.mark.parametrize("constructor", ["Right.Builder()", "Missing.Builder()"]) +def test_swift_qualified_constructor_does_not_guess_an_unrelated_method( + tmp_path: Path, constructor: str, +): + path = tmp_path / "Types.swift" + path.write_text( + "class Left {\n class Builder {\n func setEnabled(_ v: Bool) {}\n }\n}\n" + "class Right {\n class Builder {}\n}\n" + f"class Caller {{\n func run() {{\n let b = {constructor}\n b.setEnabled(true)\n }}\n}}\n", + encoding="utf-8", + ) + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + assert not result["failed_sources"] + assert not result["skipped_sources"] + nodes = {node["id"]: node for node in result["nodes"]} + assert not [ + edge for edge in result["edges"] + if edge["relation"] == "calls" + and nodes[edge["source"]]["label"] == ".run()" + and nodes[edge["target"]]["label"] == ".setEnabled()" + ] + + +@pytest.mark.parametrize("child", [ + "public class Child : Parent { public new class Builder {} }", + "public class Child : Missing {}", +]) +def test_csharp_inherited_nested_lookup_does_not_bypass_a_missing_or_hidden_type( + tmp_path: Path, child: str, +): + path = tmp_path / "Types.cs" + path.write_text( + "public class Parent { public class Builder { public void setEnabled(bool v) {} } }\n" + + child + + "\npublic class Caller { public void run(Child.Builder b) { b.setEnabled(true); } }\n", + encoding="utf-8", + ) + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + assert not result["failed_sources"] + assert not result["skipped_sources"] + nodes = {node["id"]: node for node in result["nodes"]} + assert not [ + edge for edge in result["edges"] + if edge["relation"] == "calls" + and nodes[edge["source"]]["label"] == ".run()" + and nodes[edge["target"]]["label"] == ".setEnabled()" + ] + + +@pytest.mark.parametrize("qualifier,namespace,type_path", [ + ("Left.Builder", "Local", "Left.Builder"), + ("global::Left.Builder", "Left", "Builder"), +]) +def test_csharp_qualified_type_respects_lexical_shadowing( + tmp_path: Path, qualifier: str, namespace: str, type_path: str, +): + path = tmp_path / "Types.cs" + path.write_text( + "namespace Left { public class Builder { public void setEnabled(bool v) {} } }\n" + "namespace Local {\n" + " public class Left { public class Builder { public void setEnabled(bool v) {} } }\n" + f" public class Caller {{ public void run({qualifier} b) {{ b.setEnabled(true); }} }}\n" + "}\n", + encoding="utf-8", + ) + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + nodes = {n["id"]: n for n in result["nodes"]} + owners = {e["target"]: nodes[e["source"]] for e in result["edges"] if e["relation"] == "method"} + targets = [ + owners[e["target"]]["metadata"] for e in result["edges"] + if e["relation"] == "calls" and nodes[e["source"]]["label"] == ".run()" + ] + assert len(targets) == 1 + assert targets[0]["namespace"] == namespace + assert targets[0]["type_path"] == type_path + + +@pytest.mark.parametrize("import_statement,receiver,expected", [ + ("import external.Builder as OtherBuilder", "Builder", True), + ("import local.Builder as LocalBuilder", "LocalBuilder", True), + ("import external.Builder as ExternalBuilder", "ExternalBuilder", False), +]) +def test_kotlin_import_alias_only_binds_its_local_name( + tmp_path: Path, import_statement: str, receiver: str, expected: bool, +): + paths = [tmp_path / "Left.kt", tmp_path / "Caller.kt"] + for path, body in zip(paths, ( + "package local\nobject Builder { fun setEnabled(v: Boolean) {} }\n", + f"package local\n{import_statement}\n" + f"class Caller {{ fun run() {{ {receiver}.setEnabled(true) }} }}\n", + )): + path.write_text(body, encoding="utf-8") + result = extract(paths, root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + nodes = {n["id"]: n for n in result["nodes"]} + targets = [ + nodes[e["target"]]["source_file"] for e in result["edges"] + if e["relation"] == "calls" and nodes[e["source"]]["label"] == ".run()" + ] + assert targets == (["Left.kt"] if expected else []) + + +INCREMENTAL_CASES = [ + case for case in CASES if case[0] in { + "csharp-nested", "csharp-namespace", "kotlin-imported-object", "swift-static", + "swift-qualified-static", "swift-qualified-parameter", + "swift-qualified-constructor", "swift-deep-qualified-constructor", + "csharp-inherited-nested", "csharp-global-inherited-nested", + } +] + + +@pytest.mark.parametrize("backend", ["watch", "cli"]) +@pytest.mark.parametrize( + "name,suffix,target,caller,competitor", INCREMENTAL_CASES, + ids=[case[0] for case in INCREMENTAL_CASES], +) +def test_changed_caller_keeps_unchanged_qualified_type_facts( + tmp_path: Path, backend: str, name: str, suffix: str, + target: str, caller: str, competitor: str, +): + from graphify.watch import _rebuild_code + + left, use, right = [tmp_path / f"{stem}.{suffix}" for stem in ("Left", "Caller", "Right")] + for path, body in zip((left, use, right), (target, caller, competitor)): + path.write_text(body, encoding="utf-8") + + def rebuild(): + if backend == "watch": + assert _rebuild_code( + tmp_path, incremental=True, no_cluster=True, acquire_lock=False, + ) + else: + result = subprocess.run( + [sys.executable, "-B", "-m", "graphify", "extract", ".", "--code-only", "--no-cluster"], + cwd=tmp_path, + env=dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1])), + capture_output=True, text=True, timeout=90, + ) + assert result.returncode == 0, result.stdout + result.stderr + + def targets(): + graph = json.loads((tmp_path / "graphify-out" / "graph.json").read_text(encoding="utf-8")) + nodes = {n["id"]: n for n in graph["nodes"]} + return { + e["target"] for e in graph.get("links", graph.get("edges", [])) + if e["relation"] == "calls" + and nodes[e["source"]]["label"].rsplit(".", 1)[-1] == "run()" + and nodes[e["target"]]["label"].rsplit(".", 1)[-1] == "setEnabled()" + and Path(nodes[e["target"]].get("source_file", "")).name == left.name + } + + rebuild() + before = targets() + assert len(before) == 1, name + use.write_text(caller + "\n// changed caller\n", encoding="utf-8") + rebuild() + assert targets() == before, name diff --git a/tests/test_ts_namespace.py b/tests/test_ts_namespace.py index ba50b7c02f..e56f5517a6 100644 --- a/tests/test_ts_namespace.py +++ b/tests/test_ts_namespace.py @@ -55,7 +55,7 @@ def test_namespace_members_still_extracted(tmp_path): "}\n") r = extract([f], cache_root=tmp_path) assert _has_node(r, "src/n.ts", "Shapes") - assert _has_node(r, "src/n.ts", "Circle") + assert _has_node(r, "src/n.ts", "Shapes.Circle") assert _has_node(r, "src/n.ts", "area")