Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphify/cross_repo_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}),
"csharp": frozenset({".cs"}),
"java": frozenset({".java"}),
"php": frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}),
"swift": frozenset({".swift"}),
}

Expand Down
101 changes: 101 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4414,6 +4414,98 @@ def _resolve_csharp_qualified_calls(
})


def _resolve_php_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve PHP member calls (``$greeter->greet()``) through the receiver's type.

The shared cross-file pass skips member calls, so a call on a typed receiver whose
method is declared in another file resolved to nothing. The per-file
``php_type_table`` names the declared type of every property, promoted constructor
parameter, typed parameter and ``new`` binding; this pass looks the receiver up
there, takes the single class declaring that type, and emits the ``calls`` edge to
its method. Always INFERRED: the type comes from the table, never from the call site
(``Helper::format()`` is a scoped call and keeps its own path).

A receiver typed to a class this corpus declares nowhere is parked on the caller for
a merged graph to finish (#3152).
"""
raw = [
rc
for result in per_file
for rc in result.get("raw_calls", [])
if rc.get("lang") == "php" and rc.get("is_member_call")
and rc.get("receiver") and rc.get("callee") and rc.get("caller_nid")
]
if not raw:
return
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
tt = result.get("php_type_table")
if tt and tt.get("path"):
type_table_by_file[tt["path"]] = tt.get("table", {})

def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()

# A genuine declaration is the target of a `contains` edge from its file node; a bare
# type reference mints a same-label stub that would otherwise make a real name ambiguous.
# Only PHP declarations count: the index is corpus-wide, so a same-named Java class
# would both answer the receiver and hide that nothing local declares its type.
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if (_lang_family(n.get("source_file")) == "php"
and n.get("id") in contained and _is_type_like_definition(n)):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])

method_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
tnode = node_by_id.get(e.get("target"))
if tnode is not None:
method_index[(e.get("source"), _key(tnode.get("label", "")))] = e["target"]

php_builtins = _LANGUAGE_BUILTIN_BASE_CLASSES.get("php", frozenset())
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in raw:
receiver, callee, caller = rc["receiver"], rc["callee"], rc["caller_nid"]
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
if not type_name or type_name in _LANGUAGE_BUILTIN_GLOBALS or type_name in php_builtins:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if not type_defs:
# Declared nowhere here — usually "in a repo this build does not contain",
# so park it for the merge (#3152). The extractor's `lang` tag already says
# who is asking, so no suffix sniff is needed.
_park_unresolved_member_call(node_by_id.get(caller), callee, type_name, "php", rc)
continue
if len(type_defs) != 1: # ambiguous -> bail (god-node guard)
continue
target = method_index.get((type_defs[0], _key(callee)))
if not target or target == caller or (caller, target) in existing_pairs:
continue
existing_pairs.add((caller, target))
all_edges.append({
"source": caller,
"target": target,
"relation": "calls",
"context": "call",
"confidence": "INFERRED",
# The rubric's discrete INFERRED scale (references/extraction-spec.md):
# a single-definition type-table hit is the high-confidence rung.
"confidence_score": 0.85,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})


def _resolve_kotlin_qualified_calls(
per_file: list[dict],
all_nodes: list[dict],
Expand Down Expand Up @@ -4608,6 +4700,15 @@ def _resolve_kotlin_qualified_calls(
"csharp_qualified_calls", frozenset({".cs"}), _resolve_csharp_qualified_calls
)
)
# PHP receiver-typed member-call resolution: `$greeter->greet()` where the method is
# declared in another file. The shared pass skips member calls, so these had no edge.
register_language_resolver(
LanguageResolver(
"php_member_calls",
frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}),
_resolve_php_member_calls,
)
)
# C# member-level interface dispatch (#3003): a call through an injected
# dependency lands on the interface's method, so the implementation sits in the
# graph unreachable from the call site. Lives in graphify.csharp_dispatch;
Expand Down
93 changes: 93 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,77 @@ def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s
if c.is_named:
_php_collect_type_refs(c, source, generic, out)

def _php_declared_type_name(node, source: bytes) -> str | None:
"""The single class name a PHP type annotation names, or None.

Only `Greeter` and `?Greeter` qualify: a union, an intersection or a primitive names
no one class, and binding a receiver to the first arm of `A|B` would be a guess.
"""
if node is None:
return None
if node.type == "optional_type":
node = next((c for c in node.children if c.is_named), None)
if node is None:
return None
if node.type != "named_type":
return None
return next((_php_name_text(c, source) for c in node.children
if c.type in ("name", "qualified_name")), None)


def _php_variable_text(node, source: bytes) -> str | None:
"""The bare name a PHP `variable_name` binds: `$greeter` -> `greeter`."""
if node is None or node.type != "variable_name":
return None
return next((_read_text(c, source) for c in node.children if c.type == "name"), None)


def _php_first_declared_type(node, source: bytes) -> str | None:
"""The first single-class type annotation among ``node``'s direct children."""
return next((name for name in (_php_declared_type_name(c, source) for c in node.children)
if name), None)


def _php_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None:
"""Collect ``name -> TypeName`` for every PHP receiver whose type is written down.

Four sources, all needed: a typed property (`private Greeter $g;`), a constructor
promotion (`__construct(private Greeter $g)`), a typed parameter, and
`$g = new Greeter()`. File-scoped and flat, first binding wins — a parameter
shadowing a property in another method must not retype the property's own calls.
Children are pushed reversed so the walk yields document order and "first" means
first in the file.
"""
stack = [root]
while stack:
n = stack.pop()
t = n.type
if t == "property_declaration":
type_name = _php_first_declared_type(n, source)
for element in n.children if type_name else ():
if element.type != "property_element":
continue
name = next((_php_variable_text(c, source) for c in element.children
if c.type == "variable_name"), None)
if name and name not in table:
table[name] = type_name
elif t in ("property_promotion_parameter", "simple_parameter"):
type_name = _php_first_declared_type(n, source)
name = next((_php_variable_text(c, source) for c in n.children
if c.type == "variable_name"), None)
if name and type_name and name not in table:
table[name] = type_name
elif t == "assignment_expression":
right = n.child_by_field_name("right")
if right is not None and right.type == "object_creation_expression":
name = _php_variable_text(n.child_by_field_name("left"), source)
type_name = next((_php_name_text(c, source) for c in right.children
if c.type in ("name", "qualified_name")), None)
if name and type_name and name not in table:
table[name] = type_name
stack.extend(reversed(n.children))


def _php_method_return_type_node(method_node):
"""Return the named_type/primitive_type node sitting after formal_parameters."""
saw_params = False
Expand Down Expand Up @@ -5477,6 +5548,17 @@ def walk_calls(
name_node = node.child_by_field_name("name")
if name_node:
callee_name = _read_text(name_node, source)
# `$this->greeter->greet()` types the receiver by the property name,
# `$greeter->greet()` by the variable; a longer chain names neither.
obj = node.child_by_field_name("object")
if obj is not None and obj.type == "variable_name":
member_receiver = _php_variable_text(obj, source)
elif obj is not None and obj.type == "member_access_expression":
inner = obj.child_by_field_name("object")
if inner is not None and inner.type == "variable_name" and (
_php_variable_text(inner, source) == "this"):
member_receiver = _read_text(
obj.child_by_field_name("name"), source)
elif config.ts_module == "tree_sitter_cpp":
# C++: function field, then field_expression/qualified_identifier
func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None
Expand Down Expand Up @@ -5636,9 +5718,14 @@ def walk_calls(
_java_defer = (
config.ts_module == "tree_sitter_java" and is_member_call
)
# PHP never defers: the receiver's type is only ever usable once the
# bare callee name misses in this file, which already leaves tgt_nid
# None and routes the call to raw_calls.
_php_keeps_in_file = config.ts_module == "tree_sitter_php"
if _python_defer or _java_defer or (
is_member_call
and member_receiver
and not _php_keeps_in_file
and (
member_receiver[:1].isupper()
or is_this_field_call
Expand Down Expand Up @@ -5715,6 +5802,8 @@ def walk_calls(
receiver_type = (receiver_types or {}).get(member_receiver or "")
if receiver_type:
rc_entry["receiver_type"] = receiver_type
if config.ts_module == "tree_sitter_php":
rc_entry["lang"] = "php"
# Kotlin fully-qualified call (#2550): the dotted prefix +
# lang tag let _resolve_kotlin_qualified_calls claim it.
if kotlin_qualified_prefix:
Expand Down Expand Up @@ -6150,6 +6239,8 @@ def _scan_js_module_dispatch(n) -> None:
# a name clash (first-binding-wins in the helper).
if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
_ts_receiver_type_table(root, source, type_table)
if config.ts_module == "tree_sitter_php":
_php_receiver_type_table(root, source, type_table)
if config.ts_module == "tree_sitter_swift":
if type_table or swift_factory_bindings:
result["swift_type_table"] = {"path": str_path, "table": type_table}
Expand All @@ -6163,6 +6254,8 @@ def _scan_js_module_dispatch(n) -> None:
result["ts_type_table"] = {"path": str_path, "table": type_table}
elif config.ts_module == "tree_sitter_cpp":
result["cpp_type_table"] = {"path": str_path, "table": type_table}
elif config.ts_module == "tree_sitter_php":
result["php_type_table"] = {"path": str_path, "table": type_table}
return result

def _python_decorator_name(deco_node, source: bytes) -> str | None:
Expand Down
13 changes: 11 additions & 2 deletions tests/test_cross_repo_member_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
`merge-graphs` and `global add` read. The two-repo graph was missing precisely the
edges that make it a call graph.

The Java, C++, C# and Swift resolvers now park those calls on the caller node and
this pass finishes them after the merge. The cases below pin what it must NOT do
The Java, C++, C#, Swift and PHP resolvers now park those calls on the caller node
and this pass finishes them after the merge. The cases below pin what it must NOT do
as much as what it must: the single-definition guard, the cross-repo-only scope,
and the language guard are what keep it from fabricating an edge from a name
collision.
Expand Down Expand Up @@ -45,6 +45,7 @@ def _needs(module: str):
needs_cpp = _needs("tree_sitter_cpp")
needs_csharp = _needs("tree_sitter_c_sharp")
needs_swift = _needs("tree_sitter_swift")
needs_php = _needs("tree_sitter_php")


def _caller(repo: str, parked: list[dict], node_id: str = "app_run",
Expand Down Expand Up @@ -373,6 +374,14 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path):
("src/Greeter.swift", "class Greeter { func greet() {} }\n"),
marks=needs_swift, id="swift-property-receiver",
),
pytest.param(
"php", "greet",
("src/App.php", "<?php\nclass App {\n private Greeter $greeter;\n"
" public function run(): void { $this->greeter->greet(); }\n}\n"),
("src/Greeter.php", "<?php\nclass Greeter {\n"
" public function greet(): void {}\n}\n"),
marks=needs_php, id="php-typed-property",
),
])
def test_each_language_parks_the_call_and_the_merge_finishes_it(
tmp_path: Path, lang: str, callee: str, app_file: tuple[str, str],
Expand Down
Loading
Loading