mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
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:
1 parent
281ccaa4ff
commit
208d0c1dcc
3 files changed
+181
-3
No files matched your search
+23
-3
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
Reference in new issue
Block a user