mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-29 17:56:41 +00:00
fix(objc): protocol not a receiver type, category fold, property/ivar receivers (#2589, #2590, #2591)
#2589 (PR #2500): a @protocol declaration (labeled <Name>) is excluded from the receiver-type index, so it no longer collides with a same-named class. #2590 (PR #2501): a category/class-extension interface is keyed off the base stem and folds into the base class instead of minting a duplicate node. #2591 (fresh): @property and ivar declarations are captured into a per-class field-type table, and a message send to a self.field / _ivar receiver resolves through it (bare field name only, so Foo.shared cannot fabricate to a FooShared class). All hold the single-definition guard and emit INFERRED. Adapts PRs #2500/#2501 (thanks @xiongjianxu); #2591 fresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
978f87cb67
commit
ba8254b3ab
+139
-3
@@ -6,6 +6,40 @@ from graphify.extractors.engine import _cpp_declarator_name, _semantic_reference
|
||||
from graphify.extractors.resolution import _resolve_c_include_path
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import re
|
||||
|
||||
# A category stem splits only when both halves look like plain ObjC identifiers, so
|
||||
# `C++Bridge.h` and `Foo+.h` are left intact.
|
||||
_OBJC_STEM_PART = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
||||
|
||||
|
||||
def _objc_category_base_stem(stem: str) -> str:
|
||||
"""Strip an ObjC category/extension suffix from a file stem (``Foo+Cat`` -> ``Foo``).
|
||||
|
||||
A category (``@interface Foo (Cat)``) or class extension (``@interface Foo ()``)
|
||||
declares members of an EXISTING class, so its class node must key to the same id
|
||||
as ``Foo.h``'s ``@interface Foo`` instead of minting a second class node (#1556).
|
||||
Only the final path segment is considered, and only a well-formed
|
||||
``Name+Suffix`` pair is split.
|
||||
|
||||
Mirrors ``_decldef_class_stem``, which already compares sibling header/impl files
|
||||
by the stem before ``+``.
|
||||
"""
|
||||
head, sep, tail = stem.rpartition("/")
|
||||
base, plus, suffix = tail.partition("+")
|
||||
if not plus or not _OBJC_STEM_PART.fullmatch(base) or not _OBJC_STEM_PART.fullmatch(suffix):
|
||||
return stem
|
||||
return f"{head}{sep}{base}"
|
||||
|
||||
|
||||
def _objc_is_category(node) -> bool:
|
||||
"""True for ``@interface/@implementation Foo (Cat)`` and ``Foo ()``.
|
||||
|
||||
The grammar emits the parentheses as anonymous children only for a category or
|
||||
class extension; a generic class (``@interface Box<T>``) uses
|
||||
``parameterized_arguments`` instead, so it is not matched.
|
||||
"""
|
||||
return any(c.type == "(" for c in node.children)
|
||||
|
||||
|
||||
def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None:
|
||||
@@ -74,6 +108,12 @@ def extract_objc(path: Path) -> dict:
|
||||
# per-file `var -> ClassName` table from `Foo *f = ...;` local declarations.
|
||||
raw_calls: list[dict] = []
|
||||
objc_type_table: dict[str, str] = {}
|
||||
# #1556: per-CLASS `field -> ClassName` tables from `@property Bar *bar;` and
|
||||
# ivar `Bar *_bar;` declarations, keyed by the class nid the .h/.m pair share
|
||||
# (preserved by _merge_decl_def_classes), so the cross-file resolver can type a
|
||||
# `[self.bar doIt]` / `[_bar doIt]` receiver. A conflicting redeclaration of the
|
||||
# same (class, field) tombstones the entry (None) — drop, don't guess.
|
||||
objc_field_types: dict[str, dict[str, str | None]] = {}
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
@@ -137,6 +177,55 @@ def extract_objc(path: Path) -> dict:
|
||||
})
|
||||
return nid
|
||||
|
||||
def _field_decl_entry(sd) -> tuple[str, str] | None:
|
||||
"""``(field, TypeName)`` from a property/ivar ``struct_declaration``, else None.
|
||||
|
||||
Precision gates (#1556): the type must be a BARE capitalized
|
||||
``type_identifier`` DIRECTLY under the struct_declaration — a
|
||||
``generic_specifier`` (``NSArray<Bar *>``) or ``typedefed_specifier``
|
||||
(``id<P>``) wraps its type_identifier, so both are naturally excluded and
|
||||
never type a receiver — and there must be exactly one ``struct_declarator``
|
||||
whose name unwraps via the C++ declarator unwrapper (identical grammar).
|
||||
"""
|
||||
type_ids = [s for s in sd.children if s.type == "type_identifier"]
|
||||
declarators = [s for s in sd.children if s.type == "struct_declarator"]
|
||||
if len(type_ids) != 1 or len(declarators) != 1:
|
||||
return None
|
||||
type_name = _read(type_ids[0]).strip()
|
||||
if not type_name or not type_name[:1].isupper():
|
||||
return None
|
||||
# struct_declarator wraps ONE pointer_declarator (`*bar`) or identifier
|
||||
# (`bar`); anything else (bitfields, arrays) has more children -> bail.
|
||||
inner = declarators[0].children
|
||||
if len(inner) != 1:
|
||||
return None
|
||||
field = _cpp_declarator_name(inner[0], source)
|
||||
if not field:
|
||||
return None
|
||||
return field, type_name
|
||||
|
||||
def _record_field_type(cls_nid: str, field: str, type_name: str) -> None:
|
||||
table = objc_field_types.setdefault(cls_nid, {})
|
||||
if field in table:
|
||||
if table[field] != type_name:
|
||||
table[field] = None # conflicting redeclaration -> drop, don't guess
|
||||
else:
|
||||
table[field] = type_name
|
||||
|
||||
def _collect_instance_variables(ivars_node, cls_nid: str) -> None:
|
||||
"""Record field types from an ``instance_variables`` block (``{ Bar *_b; }``)
|
||||
under an @interface or @implementation. No nodes/edges are emitted here;
|
||||
the table only feeds receiver typing in the cross-file resolver (#1556).
|
||||
"""
|
||||
for iv in ivars_node.children:
|
||||
if iv.type != "instance_variable":
|
||||
continue
|
||||
for sd in iv.children:
|
||||
if sd.type == "struct_declaration":
|
||||
entry = _field_decl_entry(sd)
|
||||
if entry is not None:
|
||||
_record_field_type(cls_nid, *entry)
|
||||
|
||||
def walk(node, parent_nid: str | None = None) -> None:
|
||||
t = node.type
|
||||
line = node.start_point[0] + 1
|
||||
@@ -187,7 +276,14 @@ def extract_objc(path: Path) -> dict:
|
||||
walk(child, parent_nid)
|
||||
return
|
||||
name = _read(identifiers[0])
|
||||
cls_nid = _make_id(stem, name)
|
||||
# A category / class extension extends an existing class, so key its
|
||||
# class node off the BASE stem (`Foo+Cat.h` -> `Foo`). Without this,
|
||||
# `Foo+Cat.h` minted a SECOND node labelled `Foo`, which made every
|
||||
# `[Foo ...]` receiver ambiguous and tripped the resolver's
|
||||
# single-definition god-node guard — destroying edges the same corpus
|
||||
# produced fine when the members lived in `Foo.h` (#1556).
|
||||
cls_stem = _objc_category_base_stem(stem) if _objc_is_category(node) else stem
|
||||
cls_nid = _make_id(cls_stem, name)
|
||||
add_node(cls_nid, name, line)
|
||||
add_edge(file_nid, cls_nid, "contains", line)
|
||||
# superclass is second identifier after ':'
|
||||
@@ -228,6 +324,13 @@ def extract_objc(path: Path) -> dict:
|
||||
type_nid = ensure_named_node(tname, prop_line)
|
||||
edges.append(_semantic_reference_edge(
|
||||
cls_nid, type_nid, "field", str_path, prop_line))
|
||||
# #1556: a bare capitalized property type also types the
|
||||
# `[self.field msg]` receiver in the cross-file resolver.
|
||||
entry = _field_decl_entry(sub)
|
||||
if entry is not None:
|
||||
_record_field_type(cls_nid, *entry)
|
||||
elif child.type == "instance_variables":
|
||||
_collect_instance_variables(child, cls_nid)
|
||||
elif child.type == "method_declaration":
|
||||
walk(child, cls_nid)
|
||||
return
|
||||
@@ -243,12 +346,15 @@ def extract_objc(path: Path) -> dict:
|
||||
for child in node.children:
|
||||
walk(child, parent_nid)
|
||||
return
|
||||
impl_nid = _make_id(stem, name)
|
||||
impl_stem = _objc_category_base_stem(stem) if _objc_is_category(node) else stem
|
||||
impl_nid = _make_id(impl_stem, name)
|
||||
if impl_nid not in seen_ids:
|
||||
add_node(impl_nid, name, line)
|
||||
add_edge(file_nid, impl_nid, "contains", line)
|
||||
for child in node.children:
|
||||
if child.type == "implementation_definition":
|
||||
if child.type == "instance_variables":
|
||||
_collect_instance_variables(child, impl_nid)
|
||||
elif child.type == "implementation_definition":
|
||||
for sub in child.children:
|
||||
walk(sub, impl_nid)
|
||||
return
|
||||
@@ -379,6 +485,28 @@ def extract_objc(path: Path) -> dict:
|
||||
"receiver": _read(recv),
|
||||
"lang": "objc",
|
||||
})
|
||||
elif recv is not None and recv.type == "field_expression":
|
||||
# `[self.bar doIt]`: capture ONLY the exact `self.<field>`
|
||||
# shape and stamp the BARE field name, typed via the class's
|
||||
# property/ivar table. Anything else (`obj.prop`, chains,
|
||||
# `Foo.shared`) stays dropped: passing the dotted text
|
||||
# through would let a capitalized `Foo.shared` enter the
|
||||
# explicit-class arm, where _key strips the dot and collides
|
||||
# with a real class `FooShared` — a fabricated edge (#1556).
|
||||
kids = recv.children
|
||||
if (len(kids) == 3 and kids[0].type == "identifier"
|
||||
and _read(kids[0]) == "self" and kids[1].type == "."
|
||||
and kids[2].type == "field_identifier"):
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": method_name,
|
||||
"is_member_call": True,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{n.start_point[0] + 1}",
|
||||
"receiver": _read(kids[2]),
|
||||
"receiver_kind": "self_field",
|
||||
"lang": "objc",
|
||||
})
|
||||
elif n.type == "field_expression":
|
||||
# self.name / self.product.name — dot-syntax sugar for [self name].
|
||||
# Resolve to a sibling method of the SAME class, matched by EXACT
|
||||
@@ -427,4 +555,12 @@ def extract_objc(path: Path) -> dict:
|
||||
"input_tokens": 0, "output_tokens": 0}
|
||||
if objc_type_table:
|
||||
result["objc_type_table"] = {"path": str_path, "table": objc_type_table}
|
||||
# Drop tombstoned (conflicting) entries and empty tables before export.
|
||||
field_tables = {
|
||||
cls: {f: t for f, t in tbl.items() if t}
|
||||
for cls, tbl in objc_field_types.items()
|
||||
}
|
||||
field_tables = {cls: tbl for cls, tbl in field_tables.items() if tbl}
|
||||
if field_tables:
|
||||
result["objc_field_types"] = {"path": str_path, "tables": field_tables}
|
||||
return result
|
||||
|
||||
@@ -2056,6 +2056,11 @@ def _decldef_class_stem(source_file: str) -> tuple[str, str] | None:
|
||||
return None
|
||||
return (str(p.parent), stem)
|
||||
|
||||
def _source_stem(node: dict) -> str:
|
||||
"""Filename stem of a node's ``source_file`` (``""`` when it has none)."""
|
||||
return Path(str(node.get("source_file", ""))).stem
|
||||
|
||||
|
||||
def _merge_decl_def_classes(
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
@@ -2137,10 +2142,22 @@ def _merge_decl_def_classes(
|
||||
headers.append(node)
|
||||
if not ok:
|
||||
continue
|
||||
# All from one (dir, base_stem) sibling family, with a UNIQUE header.
|
||||
if len(sibling_keys) != 1 or len(headers) != 1:
|
||||
# All from one (dir, base_stem) sibling family. Pick the declaring header.
|
||||
# Usually there is exactly one. An ObjC class whose members are split across
|
||||
# categories has several (`Foo.h`, `Foo+Cat.h`) — fold those too, keeping the
|
||||
# BASE header (the stem with no `+`), or the lowest-sorting category header
|
||||
# when the base class lives outside the corpus (`NSString+Trim.h`). Two
|
||||
# NON-category headers still bail to disambiguation, as before, so an
|
||||
# unrelated `Foo.h` / `Foo.hpp` pair is untouched.
|
||||
if len(sibling_keys) != 1 or not headers:
|
||||
continue
|
||||
keeper = headers[0]
|
||||
if len(headers) == 1:
|
||||
keeper = headers[0]
|
||||
else:
|
||||
base_headers = [h for h in headers if "+" not in _source_stem(h)]
|
||||
if len(base_headers) > 1:
|
||||
continue
|
||||
keeper = base_headers[0] if base_headers else min(headers, key=_source_stem)
|
||||
for node in group:
|
||||
if node is not keeper:
|
||||
drop_objs.add(id(node))
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""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"] == []
|
||||
@@ -0,0 +1,97 @@
|
||||
"""ObjC receiver typing must not treat a ``@protocol`` as a message receiver (#1556).
|
||||
|
||||
The ObjC extractor labels a protocol declaration ``<Name>``, and the member-call
|
||||
resolver's ``_key()`` strips the angle brackets — so a protocol and a class of the
|
||||
same name collapse to one index key. ObjC keeps protocol and class names in separate
|
||||
namespaces (``@protocol NSObject`` and ``@interface NSObject`` both exist in
|
||||
Foundation), so same-named pairs are ordinary, and both outcomes were wrong:
|
||||
|
||||
* protocol only in the corpus -> ``[Reload reload]`` bound to the PROTOCOL's method
|
||||
declaration at confidence 1.0 (a WRONG edge, not just a missing one);
|
||||
* protocol AND class -> two candidates tripped the single-definition god-node guard,
|
||||
so a call that should resolve produced NO edge.
|
||||
|
||||
Excluding protocols from the receiver-type index fixes both. Protocols remain valid
|
||||
`implements` targets; only receiver typing ignores them.
|
||||
"""
|
||||
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 _edges(result: dict, relation: str):
|
||||
"""{(source_label, target_label, confidence)} for edges of one relation."""
|
||||
return {
|
||||
(_label(result, e["source"]), _label(result, e["target"]), e.get("confidence"))
|
||||
for e in result["edges"]
|
||||
if e.get("relation") == relation
|
||||
}
|
||||
|
||||
|
||||
def test_objc_protocol_only_receiver_emits_no_call_edge(tmp_path: Path):
|
||||
"""No class named Reload exists, so `[Reload reload]` is untypable -> ZERO edges.
|
||||
|
||||
The decoy is the protocol's own `-reload` declaration: it must NOT be the target.
|
||||
"""
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Reload.h",
|
||||
"@protocol Reload <NSObject>\n- (void)reload;\n@end\n")
|
||||
_write(base / "Use.h", '#import "Reload.h"\n@interface Use : NSObject\n- (void)go;\n@end\n')
|
||||
_write(base / "Use.m",
|
||||
'#import "Use.h"\n@implementation Use\n- (void)go { [Reload reload]; }\n@end\n')
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
from_go = [e for e in result["edges"]
|
||||
if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"]
|
||||
assert from_go == []
|
||||
|
||||
|
||||
def test_objc_class_resolves_past_a_same_named_protocol(tmp_path: Path):
|
||||
"""A protocol and a class may share a name; the class must still resolve.
|
||||
|
||||
`[Locking lock]` resolves to the CLASS's `+lock`, never the protocol's `-lock`.
|
||||
"""
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Locking.h",
|
||||
"@protocol Locking <NSObject>\n- (void)lock;\n@end\n")
|
||||
_write(base / "LockingImpl.h",
|
||||
'#import "Locking.h"\n@interface Locking : NSObject\n+ (void)lock;\n@end\n')
|
||||
_write(base / "LockingImpl.m",
|
||||
'#import "LockingImpl.h"\n@implementation Locking\n+ (void)lock {}\n@end\n')
|
||||
_write(base / "Worker.h",
|
||||
'#import "LockingImpl.h"\n@interface Worker : NSObject\n- (void)run;\n@end\n')
|
||||
_write(base / "Worker.m",
|
||||
'#import "Worker.h"\n@implementation Worker\n- (void)run { [Locking lock]; }\n@end\n')
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
calls = _edges(result, "calls")
|
||||
assert ("-run", "+lock", "EXTRACTED") in calls
|
||||
assert ("-run", "-lock", "EXTRACTED") not in calls
|
||||
|
||||
|
||||
def test_objc_protocol_stays_a_valid_implements_target(tmp_path: Path):
|
||||
"""The exclusion is scoped to receiver typing: adoption edges are unaffected."""
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Reload.h",
|
||||
"@protocol Reload <NSObject>\n- (void)reload;\n@end\n")
|
||||
_write(base / "Widget.h",
|
||||
'#import "Reload.h"\n@interface Widget : NSObject <Reload>\n- (void)reload;\n@end\n')
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
implements = {(s, t) for s, t, _ in _edges(result, "implements")}
|
||||
assert ("Widget", "<Reload>") in implements
|
||||
@@ -0,0 +1,218 @@
|
||||
"""ObjC property/ivar receivers must type through the class's field table (#1556).
|
||||
|
||||
`[self.bar doIt]` and `[_ivarBar doIt]` are the ordinary ways an ObjC object talks
|
||||
to a collaborator held in a `@property` or ivar, but neither ever resolved: the
|
||||
extractor's raw-call gate admitted only bare-identifier receivers (a
|
||||
`field_expression` was dropped), and the receiver type table held only method-body
|
||||
locals. The fix records a per-CLASS `field -> ClassName` table from bare capitalized
|
||||
`@property` / ivar declarations and adds a resolver arm for the exact
|
||||
`self.<field>` receiver shape plus a field-table fallback for bare identifiers.
|
||||
|
||||
PRECISION over recall, as everywhere in this resolver:
|
||||
|
||||
* only the exact `self.<field>` receiver is captured — `obj.prop`, chains, and
|
||||
`Foo.shared` stay dropped. Passing the DOTTED text through would let a
|
||||
capitalized `Foo.shared` enter the explicit-class arm, where `_key` strips the
|
||||
dot and collides with a real class `FooShared` (a fabricated edge);
|
||||
* a `generic_specifier` (`NSArray<Bar *>`) or `typedefed_specifier` (`id<P>`)
|
||||
property never types a receiver;
|
||||
* locals shadow fields for bare identifiers, conflicts drop the entry, and the
|
||||
single-definition god-node guard still gates the type lookup.
|
||||
"""
|
||||
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 _call_edges(result: dict, relations=("calls",)):
|
||||
"""{(source_label, relation, target_label, confidence)} for the given relations."""
|
||||
out = set()
|
||||
for e in result["edges"]:
|
||||
if e.get("relation") in relations:
|
||||
out.add((
|
||||
_label(result, e["source"]),
|
||||
e["relation"],
|
||||
_label(result, e["target"]),
|
||||
e.get("confidence"),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
_BAR_H = "@interface Bar : NSObject\n- (void)doIt;\n@end\n"
|
||||
_BAR_M = '#import "Bar.h"\n@implementation Bar\n- (void)doIt {}\n@end\n'
|
||||
|
||||
|
||||
def test_objc_property_receiver_resolves(tmp_path: Path):
|
||||
# `[self.bar doIt]` with `@property Bar *bar` in Foo.h -> the field types the
|
||||
# receiver -> cross-file calls edge to Bar's -doIt (INFERRED).
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Bar.h", _BAR_H)
|
||||
_write(base / "Bar.m", _BAR_M)
|
||||
_write(base / "Foo.h",
|
||||
'#import "Bar.h"\n@interface Foo : NSObject\n'
|
||||
"@property (nonatomic, strong) Bar *bar;\n- (void)viaProperty;\n@end\n")
|
||||
_write(base / "Foo.m",
|
||||
'#import "Foo.h"\n@implementation Foo\n'
|
||||
"- (void)viaProperty { [self.bar doIt]; }\n@end\n")
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
calls = _call_edges(result)
|
||||
assert ("-viaProperty", "calls", "-doIt", "INFERRED") in calls
|
||||
|
||||
|
||||
def test_objc_ivar_receiver_resolves(tmp_path: Path):
|
||||
# `[_ivarBar doIt]` with `Bar *_ivarBar;` in the @implementation ivar block ->
|
||||
# bare identifier falls back from the (empty) local table to the field table.
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Bar.h", _BAR_H)
|
||||
_write(base / "Bar.m", _BAR_M)
|
||||
_write(base / "Foo.m",
|
||||
'#import "Bar.h"\n@implementation Foo {\n Bar *_ivarBar;\n}\n'
|
||||
"- (void)viaIvar { [_ivarBar doIt]; }\n@end\n")
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
calls = _call_edges(result)
|
||||
assert ("-viaIvar", "calls", "-doIt", "INFERRED") in calls
|
||||
|
||||
|
||||
def test_objc_header_ivar_block_receiver_resolves(tmp_path: Path):
|
||||
# The `@interface Foo { Bar *_bar; }` ivar block records the same way.
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Bar.h", _BAR_H)
|
||||
_write(base / "Bar.m", _BAR_M)
|
||||
_write(base / "Foo.h",
|
||||
'#import "Bar.h"\n@interface Foo : NSObject {\n Bar *_bar;\n}\n'
|
||||
"- (void)viaIvar;\n@end\n")
|
||||
_write(base / "Foo.m",
|
||||
'#import "Foo.h"\n@implementation Foo\n'
|
||||
"- (void)viaIvar { [_bar doIt]; }\n@end\n")
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
calls = _call_edges(result)
|
||||
assert ("-viaIvar", "calls", "-doIt", "INFERRED") in calls
|
||||
|
||||
|
||||
def test_objc_dotted_class_receiver_fabricates_nothing(tmp_path: Path):
|
||||
"""The no-fabrication decoy: `[Foo.shared doIt]` next to a REAL class FooShared.
|
||||
|
||||
A naive widen that passed the dotted receiver text through would enter the
|
||||
explicit-class arm, where `_key("Foo.shared")` == `_key("FooShared")` — binding
|
||||
the call to an unrelated class at confidence 1.0. Only `self.<field>` is
|
||||
captured, so this receiver must yield ZERO edges from the caller.
|
||||
"""
|
||||
base = tmp_path / "src"
|
||||
_write(base / "FooShared.h",
|
||||
"@interface FooShared : NSObject\n- (void)doIt;\n@end\n")
|
||||
_write(base / "FooShared.m",
|
||||
'#import "FooShared.h"\n@implementation FooShared\n- (void)doIt {}\n@end\n')
|
||||
_write(base / "Use.m",
|
||||
'#import "FooShared.h"\n@implementation Use\n'
|
||||
"- (void)go { [Foo.shared doIt]; }\n@end\n")
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
from_go = [e for e in result["edges"]
|
||||
if e.get("relation") in ("calls", "references")
|
||||
and _label(result, e["source"]) == "-go"]
|
||||
assert from_go == []
|
||||
|
||||
|
||||
def test_objc_local_shadows_property_field(tmp_path: Path):
|
||||
# A local `Baz *bar` shadows the class's `@property Bar *bar`; `[bar m]` must
|
||||
# resolve via the LOCAL's type (Baz's -m), never the property's (Bar's -m).
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Bar.h", "@interface Bar : NSObject\n- (void)m;\n@end\n")
|
||||
_write(base / "Bar.m", '#import "Bar.h"\n@implementation Bar\n- (void)m {}\n@end\n')
|
||||
_write(base / "Baz.h", "@interface Baz : NSObject\n- (void)m;\n@end\n")
|
||||
_write(base / "Baz.m", '#import "Baz.h"\n@implementation Baz\n- (void)m {}\n@end\n')
|
||||
_write(base / "Foo.h",
|
||||
'#import "Bar.h"\n@interface Foo : NSObject\n'
|
||||
"@property (nonatomic, strong) Bar *bar;\n- (void)go;\n@end\n")
|
||||
_write(base / "Foo.m",
|
||||
'#import "Foo.h"\n#import "Baz.h"\n@implementation Foo\n'
|
||||
"- (void)go {\n Baz *bar = [[Baz alloc] init];\n [bar m];\n}\n@end\n")
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
src_by_id = {n["id"]: n.get("source_file") for n in result["nodes"]}
|
||||
m_calls = [
|
||||
e for e in result["edges"]
|
||||
if e.get("relation") == "calls"
|
||||
and _label(result, e["source"]) == "-go"
|
||||
and _label(result, e["target"]) == "-m"
|
||||
]
|
||||
assert len(m_calls) == 1
|
||||
assert Path(src_by_id[m_calls[0]["target"]]).name == "Baz.h"
|
||||
|
||||
|
||||
def test_objc_ambiguous_field_type_emits_no_edge(tmp_path: Path):
|
||||
# Two in-corpus classes labelled Bar -> the property receiver's type lookup has
|
||||
# two candidates and the single-definition god-node guard bails: ZERO edges.
|
||||
base = tmp_path / "src"
|
||||
for d in ("a", "b"):
|
||||
_write(base / d / "Bar.h", _BAR_H)
|
||||
_write(base / d / "Bar.m", _BAR_M)
|
||||
_write(base / "Foo.h",
|
||||
'#import "a/Bar.h"\n@interface Foo : NSObject\n'
|
||||
"@property (nonatomic, strong) Bar *bar;\n- (void)go;\n@end\n")
|
||||
_write(base / "Foo.m",
|
||||
'#import "Foo.h"\n@implementation Foo\n- (void)go { [self.bar doIt]; }\n@end\n')
|
||||
result = extract(sorted(base.rglob("*.[hm]")), cache_root=tmp_path / "cache")
|
||||
|
||||
from_go = [e for e in result["edges"]
|
||||
if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"]
|
||||
assert from_go == []
|
||||
|
||||
|
||||
def test_objc_generic_and_protocol_typed_properties_are_never_recorded(tmp_path: Path):
|
||||
# `NSArray<Bar *> *items` (generic_specifier) and `id<Locking> locker`
|
||||
# (typedefed_specifier) never enter the field table -> no edge, no guess.
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Bar.h", _BAR_H)
|
||||
_write(base / "Bar.m", _BAR_M)
|
||||
_write(base / "Foo.h",
|
||||
'#import "Bar.h"\n@interface Foo : NSObject\n'
|
||||
"@property (nonatomic) NSArray<Bar *> *items;\n"
|
||||
"@property (nonatomic) id<Locking> locker;\n"
|
||||
"- (void)viaGeneric;\n- (void)viaProtocol;\n@end\n")
|
||||
_write(base / "Foo.m",
|
||||
'#import "Foo.h"\n@implementation Foo\n'
|
||||
"- (void)viaGeneric { [self.items doIt]; }\n"
|
||||
"- (void)viaProtocol { [self.locker doIt]; }\n@end\n")
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
typed = [e for e in result["edges"]
|
||||
if e.get("relation") == "calls"
|
||||
and _label(result, e["source"]) in ("-viaGeneric", "-viaProtocol")]
|
||||
assert typed == []
|
||||
|
||||
|
||||
def test_objc_local_receiver_and_alloc_reference_unchanged(tmp_path: Path):
|
||||
# The pre-existing paths still work next to the new ones: a `Foo *f` local
|
||||
# types `[f doThing]` (INFERRED) and `[[Foo alloc] init]` still emits the
|
||||
# `references` edge to the allocated type.
|
||||
base = tmp_path / "src"
|
||||
_write(base / "Foo.h", "@interface Foo : NSObject\n- (void)doThing;\n@end\n")
|
||||
_write(base / "Foo.m", '#import "Foo.h"\n@implementation Foo\n- (void)doThing {}\n@end\n')
|
||||
_write(base / "Bar.m",
|
||||
'#import "Foo.h"\n@implementation Bar\n'
|
||||
"- (void)viaLocal {\n Foo *f = [[Foo alloc] init];\n [f doThing];\n}\n@end\n")
|
||||
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
||||
|
||||
calls = _call_edges(result)
|
||||
assert ("-viaLocal", "calls", "-doThing", "INFERRED") in calls
|
||||
refs = _call_edges(result, relations=("references",))
|
||||
assert any(s == "-viaLocal" and t == "Foo" for s, _, t, _ in refs)
|
||||
Reference in New Issue
Block a user