fix(objc): remap the field->type table keys with the ids they hold (#3150)

The Objective-C field->type table is keyed by class node id, but the id-carrier rewrites
(prefix strip, symbol canonicalisation, cache portability round-trip) never remapped those
keys, so after graphify update the keys went stale and every self.field / obj.field method
call missed resolution. Remap the table keys alongside the existing id remaps (in-process
and in the cache round-trip); a dropped key can only cost a missed edge, never a phantom.
This commit is contained in:
abhay-codes07 authored and safishamsi committed 2026-08-29 16:14:29 +01:00
1 parent 281ccaa4ff
commit 208d0c1dcc
3 files changed
+181 -3

No files matched your search

+23 -3
View File
@@ -788,12 +788,30 @@ def _portability_anchors(path: "str | Path", root: "str | Path") -> tuple[list[s
return id_anchors, id_restore, path_anchors, str(root_resolved)
def _rewrite_id_keyed_table_keys(payload: object, fn) -> None:
"""Apply ``fn`` to objc_field_types["tables"] KEYS (#3150).
That table is the one extractor bucket keyed BY node id, which
:func:`_rewrite_strings` deliberately never touches - so a cached ObjC
shard replayed under another root kept absolute-derived class ids as keys
while the node ids themselves were re-anchored, and the receiver-typing
pass missed every class.
"""
ft = payload.get("objc_field_types") if isinstance(payload, dict) else None
tables = ft.get("tables") if isinstance(ft, dict) else None
if isinstance(tables, dict):
ft["tables"] = {
(fn(k) if isinstance(k, str) else k): v for k, v in tables.items()
}
def _rewrite_strings(obj: object, fn) -> None:
"""Apply ``fn`` to every string VALUE reachable in ``obj``, in place.
Values only, never dict keys: no extractor bucket is keyed by a node id or a
path (the ``*_type_table`` maps are ``name -> type``), and rewriting keys
could silently collide two entries into one.
Values only, never dict keys: rewriting keys blindly could silently
collide two entries into one. The single id-keyed bucket -
``objc_field_types["tables"]`` - is handled by
:func:`_rewrite_id_keyed_table_keys` beside each call to this (#3150).
"""
if isinstance(obj, dict):
items: "Iterable" = obj.items()
@@ -845,6 +863,7 @@ def _relativize_ids_in(payload: dict, path: "str | Path", root: Path) -> None:
return value
_rewrite_strings(payload, anchor)
_rewrite_id_keyed_table_keys(payload, anchor)
def _absolutize_ids_in(payload: dict, path: "str | Path", root: Path) -> None:
@@ -873,6 +892,7 @@ def _absolutize_ids_in(payload: dict, path: "str | Path", root: Path) -> None:
return value
_rewrite_strings(payload, restore)
_rewrite_id_keyed_table_keys(payload, restore)
def _absolutize_source_files_in(payload: dict, root: Path) -> None:
+24
View File
@@ -2487,6 +2487,26 @@ def _augment_js_reexport_edges(
# Header / implementation file-extension pairing for the decl/def class merge.
def _remap_objc_field_tables(per_file: list, mapping: dict) -> None:
"""Rewrite objc_field_types["tables"] KEYS through an id remap (#3150).
The #2591 field->type tables are the one extractor bucket keyed BY class
node id. The #1529 passes rewrote node ids, edge endpoints,
raw_calls[].caller_nid and swift_extensions[].nid but not these keys, so
whenever a common absolute prefix was stripped (always via
`graphify update <dir>`) the table keys went stale, every
field_types_by_class.get(cls) missed, and [self.<field> ...] sends
resolved to nothing - #2591 was inert through the CLI.
"""
for result in per_file:
ft = result.get("objc_field_types") if isinstance(result, dict) else None
tables = ft.get("tables") if isinstance(ft, dict) else None
if not isinstance(tables, dict):
continue
if any(k in mapping for k in tables):
ft["tables"] = {mapping.get(k, k): v for k, v in tables.items()}
def _merge_swift_extensions(
per_file: list[dict],
all_nodes: list[dict],
@@ -6209,6 +6229,9 @@ def extract(
en = ext.get("nid")
if en in id_remap:
ext["nid"] = id_remap[en]
# objc_field_types["tables"] is keyed BY class node id - the one bucket
# the #1529 rewrites missed (#3150).
_remap_objc_field_tables(per_file, id_remap)
if prefix_remap:
sym_remap: dict[str, str] = {}
edge_alias_candidates: dict[str, set[str]] = {}
@@ -6276,6 +6299,7 @@ def extract(
en = ext.get("nid")
if en in sym_remap:
ext["nid"] = sym_remap[en]
_remap_objc_field_tables(per_file, sym_remap)
if edge_alias_candidates:
def _edge_key(edge: dict) -> str:
# target_file is a transient stamp (#1814/#1983); exclude it
+134
View File
@@ -0,0 +1,134 @@
"""The ObjC field->type table must survive the id remaps (#3150).
`_resolve_objc_member_calls`' table (#2591) is the one extractor bucket keyed
BY class node id. The #1529 passes rewrote node ids, edge endpoints,
`raw_calls[].caller_nid` and `swift_extensions[].nid` — but not those keys.
The remap fires whenever the input paths carry a common absolute prefix,
i.e. always via `graphify update <dir>`, so `[self.<field> …]` receiver
typing was inert through the CLI and worked only in tests, which hand
extract() already-relative paths. The cached shard had the same split: the
portability rewrite re-anchored every id except the table keys.
"""
from __future__ import annotations
import io
from contextlib import redirect_stdout
from pathlib import Path
import pytest
from graphify.extract import extract
try:
import tree_sitter_objc # noqa: F401
HAVE_OBJC = True
except ImportError:
HAVE_OBJC = False
FILES = {
"src/Greeter.h": (
"#import <Foundation/Foundation.h>\n"
"@interface Greeter : NSObject\n- (void)greet;\n@end\n"),
"src/Greeter.m": (
'#import "Greeter.h"\n@implementation Greeter\n'
'- (void)greet { NSLog(@"hi"); }\n@end\n'),
"src/Direct.h": (
"#import <Foundation/Foundation.h>\n#import \"Greeter.h\"\n"
"@interface Direct : NSObject\n"
"@property (nonatomic, strong) Greeter *greeter;\n- (void)run;\n@end\n"),
"src/Direct.m": (
'#import "Direct.h"\n@implementation Direct\n'
"- (void)run { [self.greeter greet]; }\n@end\n"),
}
def _write(tmp_path):
for name, body in FILES.items():
p = tmp_path / name
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body, encoding="utf-8")
return tmp_path
def _calls(result):
labels = {n["id"]: n["label"] for n in result["nodes"]}
return {(labels.get(e["source"]), labels.get(e["target"]))
for e in result["edges"] if e.get("relation") == "calls"}
needs_objc = pytest.mark.skipif(not HAVE_OBJC, reason="tree-sitter-objc not installed")
@needs_objc
def test_receiver_typing_survives_absolute_input_paths(tmp_path):
"""The CLI shape: absolute inputs, common prefix stripped by the #1529
remap. This is the run where #2591 emitted zero edges."""
corpus = _write(tmp_path)
cache = tmp_path / "out"
with redirect_stdout(io.StringIO()):
r = extract([(corpus / n).resolve() for n in FILES], cache_root=cache, parallel=False)
assert ("-run", "-greet") in _calls(r), sorted(_calls(r))
@needs_objc
def test_receiver_typing_still_works_with_relative_paths(tmp_path, monkeypatch):
"""The shape the original #2591 tests used — must keep working."""
corpus = _write(tmp_path)
monkeypatch.chdir(corpus)
cache = tmp_path / "out"
with redirect_stdout(io.StringIO()):
r = extract([Path(n) for n in FILES], cache_root=cache, parallel=False)
assert ("-run", "-greet") in _calls(r)
@needs_objc
def test_a_cached_shard_replays_with_consistent_table_keys(tmp_path):
"""Warm-cache CLI run: the shard is written on the first pass and replayed
on the second; the table keys must still match the node ids."""
corpus = _write(tmp_path)
cache = tmp_path / "out"
paths = [(corpus / n).resolve() for n in FILES]
with redirect_stdout(io.StringIO()):
extract(paths, cache_root=cache, parallel=False) # cold: writes shards
r = extract(paths, cache_root=cache, parallel=False) # warm: replays them
assert ("-run", "-greet") in _calls(r), sorted(_calls(r))
def test_the_in_process_remap_rewrites_the_table_keys():
"""Unit form of the CLI-path fix: the same mapping that rewrites node ids
must rewrite the table keys."""
try:
from graphify.extract import _remap_objc_field_tables
except ImportError: # pre-fix tree
pytest.skip("pre-fix tree")
per_file = [{"objc_field_types": {"path": "src/Direct.h",
"tables": {"abs_slug_direct": {"greeter": "Greeter"}}}},
{"nodes": []}]
_remap_objc_field_tables(per_file, {"abs_slug_direct": "src_direct_direct"})
assert per_file[0]["objc_field_types"]["tables"] == {"src_direct_direct": {"greeter": "Greeter"}}
def test_cache_portability_rewrites_the_table_keys(tmp_path):
"""Round-trip a payload through the #2257 portability rewrite: the class id
inside the table key must follow the node id."""
from graphify.cache import _absolutize_ids_in, _relativize_ids_in
from graphify.extractors.base import _make_id
root = tmp_path / "proj"
root.mkdir()
f = root / "Direct.m"
f.write_text("@implementation Direct\n@end\n", encoding="utf-8")
abs_id = _make_id(str(root)) + "_direct_direct"
assert _relativize_ids_in is not None
payload = {
"nodes": [{"id": abs_id, "label": "Direct"}],
"edges": [],
"objc_field_types": {"path": str(f), "tables": {abs_id: {"greeter": "Greeter"}}},
}
_relativize_ids_in(payload, f, root)
stored_key = next(iter(payload["objc_field_types"]["tables"]))
assert stored_key == payload["nodes"][0]["id"], "key and id diverged on store"
_absolutize_ids_in(payload, f, root)
restored_key = next(iter(payload["objc_field_types"]["tables"]))
assert restored_key == payload["nodes"][0]["id"], "key and id diverged on load"
assert payload["objc_field_types"]["tables"][restored_key] == {"greeter": "Greeter"}