diff --git a/graphify/cache.py b/graphify/cache.py
index a12bcaeed..622ba6cff 100644
--- a/graphify/cache.py
+++ b/graphify/cache.py
@@ -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 anchor(value: str) -> str:
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 restore(value: str) -> str:
return value
_rewrite_strings(payload, restore)
+ _rewrite_id_keyed_table_keys(payload, restore)
def _absolutize_source_files_in(payload: dict, root: Path) -> None:
diff --git a/graphify/extract.py b/graphify/extract.py
index f68757a85..bed61e4ee 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -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
`) the table keys went stale, every
+ field_types_by_class.get(cls) missed, and [self. ...] 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 _portable_out_of_root_sf(p: Path) -> str:
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 _portable_out_of_root_sf(p: Path) -> str:
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
diff --git a/tests/test_objc_field_table_remap.py b/tests/test_objc_field_table_remap.py
new file mode 100644
index 000000000..ba89205ab
--- /dev/null
+++ b/tests/test_objc_field_table_remap.py
@@ -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 `, so `[self. …]` 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 \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 \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"}