Files
graphify/tests/test_objc_category_interfaces.py
T
xiongjianxu 6e6d5dbbfd fix(extract): resolve ObjC member calls when a class splits across header/impl dirs
`_merge_decl_def_classes` folds an @interface/@implementation pair into one node
only when both live in the SAME directory (the id embeds the directory path,
#1556). A class whose `.h` sits in `include/` and `.m` in `src/` therefore mints
two same-labelled nodes that never merge, so `[w render]` saw two type-def
candidates, tripped the member-call resolver's single-definition god-node guard
(`if len(type_defs) != 1: continue`), and emitted NO edge -- the same corpus
resolved fine when the pair shared a directory. Header/impl directory splits are
common layout, so this hit ordinary code.

Replace the three per-arm god-node bails with a shared `_resolve_member_target`
that unions the candidates' members and binds the selector to the sole candidate
that DEFINES it (an @implementation in `.m`/`.mm`, or a merged node carrying
`definition_file`). A header-only node is a bare declaration and does not count,
so the split pair resolves to its one real definition. Two unrelated same-named
classes that each define the selector still yield two definitions and stay
ambiguous -- the guard holds for the genuinely-ambiguous case it was meant for.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 510934c686)
2026-10-22 13:46:53 +01:00

151 lines
6.5 KiB
Python

"""ObjC category / class-extension interfaces must fold into the base class (#1556).
`@interface Foo (Cat)` in `Foo+Cat.h` declares members of an EXISTING class, but the
extractor keyed its class node off the file stem `Foo+Cat`, minting a SECOND node
labelled `Foo`. Every `[Foo ...]` receiver then had two type-def candidates, tripped
the member-call resolver's single-definition god-node guard, and produced NO edge —
so moving a method from `Foo.h` into a category silently destroyed call edges the
same corpus resolved fine before. Categories are pervasive in real ObjC, so this hit
ordinary code, not an edge case.
The class node now keys off the base stem, and `_merge_decl_def_classes` folds the
category header into the base header instead of bailing on "more than one header".
"""
from __future__ import annotations
from pathlib import Path
from graphify.extract import extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def _label(result: dict, nid: str) -> str:
for n in result["nodes"]:
if n["id"] == nid:
return n.get("label", "")
return f"?{nid}"
def _calls(result: dict):
"""{(source_label, target_label, confidence)} over `calls` edges."""
return {
(_label(result, e["source"]), _label(result, e["target"]), e.get("confidence"))
for e in result["edges"]
if e.get("relation") == "calls"
}
def _nodes_labelled(result: dict, label: str):
return [n for n in result["nodes"] if n.get("label") == label]
_BASE_H = "@interface Base : NSObject\n@end\n"
_CALLER = (
'#import "Base.h"\n@interface Caller : NSObject\n- (void)go;\n@end\n',
'#import "Caller.h"\n@implementation Caller\n- (void)go { [Base useIt]; }\n@end\n',
)
def test_objc_category_method_is_reachable_from_another_class(tmp_path: Path):
"""The headline case: `-useIt` declared in a category still resolves.
Before, `Base+Extra.h` minted a second `Base` node, so `[Base useIt]` was
ambiguous and emitted nothing.
"""
base = tmp_path / "src"
_write(base / "Base.h", _BASE_H)
_write(base / "Base+Extra.h",
'#import "Base.h"\n@interface Base (Extra)\n- (void)useIt;\n@end\n')
_write(base / "Base+Extra.m",
'#import "Base+Extra.h"\n@implementation Base (Extra)\n- (void)useIt {}\n@end\n')
_write(base / "Caller.h", _CALLER[0])
_write(base / "Caller.m", _CALLER[1])
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
assert len(_nodes_labelled(result, "Base")) == 1
assert ("-go", "-useIt", "EXTRACTED") in _calls(result)
def test_objc_class_extension_folds_into_the_base_class(tmp_path: Path):
"""An anonymous class extension (`@interface Base ()`) folds the same way."""
base = tmp_path / "src"
_write(base / "Base.h", "@interface Base : NSObject\n- (void)pub;\n@end\n")
_write(base / "Base.m",
'#import "Base.h"\n'
"@interface Base ()\n- (void)priv;\n@end\n"
"@implementation Base\n- (void)pub { [self priv]; }\n- (void)priv {}\n@end\n")
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
assert len(_nodes_labelled(result, "Base")) == 1
assert ("-pub", "-priv", "EXTRACTED") in _calls(result)
def test_objc_non_category_interface_in_a_plus_named_file_is_untouched(tmp_path: Path):
"""The fold is keyed on the CATEGORY SYNTAX, not on the `+` in the filename.
`Extra+Helpers.h` declaring a plain `@interface Extra` (no parentheses) must keep
its own stem, so it does not silently merge into an unrelated `Extra.h`.
"""
base = tmp_path / "src"
_write(base / "Extra+Helpers.h", "@interface Helper : NSObject\n- (void)help;\n@end\n")
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
helper = _nodes_labelled(result, "Helper")
assert len(helper) == 1
assert helper[0]["id"].endswith("extra_helpers_helper")
def test_objc_same_named_categories_in_different_directories_stay_distinct(tmp_path: Path):
"""Two unrelated `Thing` classes in different directories must not merge.
The id embeds the full directory path, so the base-stem rewrite cannot conflate
them; a `[Thing act]` receiver stays ambiguous and yields no edge (god-node guard).
"""
base = tmp_path / "src"
for d in ("a", "b"):
_write(base / d / "Thing.h", "@interface Thing : NSObject\n@end\n")
_write(base / d / "Thing+Ops.h",
'#import "Thing.h"\n@interface Thing (Ops)\n- (void)act;\n@end\n')
_write(base / d / "Thing+Ops.m",
'#import "Thing+Ops.h"\n@implementation Thing (Ops)\n- (void)act {}\n@end\n')
_write(base / "Use.m",
'#import "a/Thing+Ops.h"\n@implementation Use\n- (void)go { [Thing act]; }\n@end\n')
result = extract(sorted(base.rglob("*.[hm]")), cache_root=tmp_path / "cache")
assert len(_nodes_labelled(result, "Thing")) == 2
assert [e for e in result["edges"]
if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"] == []
def test_objc_class_split_across_header_and_impl_dirs_resolves_members(tmp_path: Path):
"""One class with `.h` in `include/` and `.m` in `src/` still resolves `[w render]`.
`_merge_decl_def_classes` folds decl/def pairs only within a directory, so this
split mints two same-labelled `Widget` nodes that never merge. The resolver unions
their members and binds the selector to the sole DEFINITION (the `.m` node), rather
than bailing on "two candidates" — the header-only node is a bare declaration.
"""
_write(tmp_path / "include" / "Widget.h",
"@interface Widget : NSObject\n- (void)render;\n@end\n")
_write(tmp_path / "src" / "Widget.m",
'#import "Widget.h"\n@implementation Widget\n- (void)render {}\n@end\n')
_write(tmp_path / "src" / "Caller.m",
'#import "Widget.h"\n@implementation Caller\n'
"- (void)go { Widget *w = [Widget new]; [w render]; }\n@end\n")
result = extract(sorted(tmp_path.rglob("*.[hm]")), cache_root=tmp_path / "cache")
assert len(_nodes_labelled(result, "Widget")) == 2
render_defs = [n for n in _nodes_labelled(result, "-render")
if str(n.get("source_file", "")).endswith(".m")]
assert len(render_defs) == 1
hits = [e for e in result["edges"]
if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"]
assert len(hits) == 1
assert hits[0]["target"] == render_defs[0]["id"]
assert hits[0].get("confidence") == "INFERRED"