Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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]] = {}
Expand Down Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions tests/test_objc_field_table_remap.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading