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"}