mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-25 16:22:30 +00:00
fix(extract): keep the checkout path out of ObjC category ids
An ObjC category (`@interface Foo (Cat)` in `Foo+Cat.h`) keys its class node and methods off the base stem `Foo`, so they fold into `Foo.h`'s class (#1556). extract() turns an id minted from an absolute input path into the repo-relative form only when it starts with its own file's stem prefix (`.../Foo+Cat`), so these ids kept the absolute form. A category on a class outside the corpus (`NSString (Extra)`) got a node id with the checkout path in it, and so did the methods of a category on an in-corpus class. graph.json then carried the path (#502), two checkouts of one repo gave different graphs, and an incremental rebuild in a moved or copied checkout added duplicate nodes. The remap now also registers a category file's base-stem prefix, after the file's own prefixes. On SDWebImage, AFNetworking and Kingfisher, 334 / 51 / 17 ids carried the checkout path; now 0, two checkouts give identical graphs, and absolute and relative input agree. Every edge, compared by label and file, is unchanged. Through the CLI, SDWebImage's graph drops 125 duplicate category nodes (a leaked copy beside the clean copy reached through its include/ symlink) and one false reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b90b9df794
commit
11fe66a3cf
2 files changed
+96
-1
No files matched your search
+15
-1
@@ -186,7 +186,7 @@ from graphify.extractors.engine import PYTEST_FIXTURE_UNTYPED, REFERENCE_CONTEXT
|
||||
|
||||
from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401
|
||||
|
||||
from graphify.extractors.objc import _objc_local_var_types, extract_objc # noqa: E402,F401
|
||||
from graphify.extractors.objc import _objc_category_base_stem, _objc_local_var_types, extract_objc # noqa: E402,F401
|
||||
|
||||
from graphify.extractors.julia import extract_julia # noqa: E402,F401
|
||||
|
||||
@@ -9811,6 +9811,20 @@ def extract(
|
||||
old_pref_abs = _file_node_id(path_abs)
|
||||
if old_pref_abs != new_id and old_pref_abs != old_pref:
|
||||
old_prefs.append((old_pref_abs, new_id))
|
||||
# An ObjC category (`Foo+Cat.h`) keys its class node and methods off the
|
||||
# BASE stem `Foo`, so they fold into `Foo.h`'s class (#1556). Those ids
|
||||
# start with the base stem's prefix, not this file's, so register it as
|
||||
# well, after the file's own prefixes, or they keep the absolute-path form
|
||||
# and the checkout location leaks into the graph (#502).
|
||||
if "+" in rel.name:
|
||||
rel_stem = _file_stem(rel)
|
||||
base_rel = _objc_category_base_stem(rel_stem)
|
||||
if base_rel != rel_stem:
|
||||
new_base = _make_id(base_rel)
|
||||
for _form in (path, path_abs):
|
||||
old_base = _make_id(_objc_category_base_stem(_file_stem(_form)))
|
||||
if old_base != new_base and (old_base, new_base) not in old_prefs:
|
||||
old_prefs.append((old_base, new_base))
|
||||
# Bash entrypoint node ids append "__entry" to the file-level id
|
||||
# (extractors/bash.py), so a script-invocation edge endpoint minted
|
||||
# from an out-of-batch target path keeps a suffixed absolute-derived
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""An ObjC category's node ids must not carry the checkout path.
|
||||
|
||||
A category (`@interface Foo (Cat)` in `Foo+Cat.h`) keys its class node and methods
|
||||
off the BASE stem `Foo`, so they fold into `Foo.h`'s class (#1556). extract() turns
|
||||
an id minted from an absolute input path into the repo-relative form only when it
|
||||
starts with its own file's stem prefix (`.../Foo+Cat`), so these ids kept the
|
||||
absolute form: `/home/me/proj/Lib/NSString+Extra.h` produced a `NSString` node
|
||||
`home_me_proj_lib_nsstring_nsstring`. A method of a category on an in-corpus class
|
||||
(`Foo (Cat) -bar`) kept it too. The same repo checked out in two places gave two
|
||||
different graphs, and graph.json carried the checkout path (#502).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import extract
|
||||
from graphify.extractors.base import _make_id
|
||||
|
||||
_FILES = {
|
||||
"Lib/Foo.h": "#import <Foundation/Foundation.h>\n@interface Foo : NSObject\n- (void)base;\n@end\n",
|
||||
"Lib/Foo+Cat.h": '#import "Foo.h"\n@interface Foo (Cat)\n- (void)bar;\n@end\n',
|
||||
"Lib/Foo+Cat.m": '#import "Foo+Cat.h"\n@implementation Foo (Cat)\n- (void)bar { [self base]; }\n@end\n',
|
||||
"Lib/NSString+Extra.h": "#import <Foundation/Foundation.h>\n@interface NSString (Extra)\n- (void)baz;\n@end\n",
|
||||
"Lib/NSString+Extra.m": '#import "NSString+Extra.h"\n@implementation NSString (Extra)\n- (void)baz {}\n@end\n',
|
||||
}
|
||||
|
||||
|
||||
def _project(root: Path) -> list[Path]:
|
||||
for rel, text in _FILES.items():
|
||||
(root / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(root / rel).write_text(text, encoding="utf-8")
|
||||
return sorted(root / rel for rel in _FILES)
|
||||
|
||||
|
||||
def _extract(root: Path, paths: list[Path], cache: Path) -> dict:
|
||||
return extract(paths, root=root, cache_root=cache, parallel=False)
|
||||
|
||||
|
||||
def _edges(result: dict) -> set[tuple[str, str, str]]:
|
||||
return {(e["source"], e["relation"], e["target"]) for e in result["edges"]}
|
||||
|
||||
|
||||
def test_no_id_carries_the_checkout_path(tmp_path: Path):
|
||||
root = tmp_path / "checkout" / "proj"
|
||||
result = _extract(root, _project(root), tmp_path / "cache")
|
||||
slug = _make_id(str(root))
|
||||
leaked = sorted({n["id"] for n in result["nodes"] if slug in n["id"]}
|
||||
| {x for e in result["edges"] for x in (e["source"], e["target"]) if slug in x})
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_category_class_and_methods_get_repo_relative_ids(tmp_path: Path):
|
||||
root = tmp_path / "proj"
|
||||
result = _extract(root, _project(root), tmp_path / "cache")
|
||||
ids = {n["id"]: n["label"] for n in result["nodes"]}
|
||||
# A category on a class outside the corpus.
|
||||
assert ids.get("lib_nsstring_nsstring") == "NSString"
|
||||
assert ids.get("lib_nsstring_nsstring_baz") == "-baz"
|
||||
# A category method folds under the in-corpus class, named like its own methods.
|
||||
assert ids.get("lib_foo_foo") == "Foo"
|
||||
assert ids.get("lib_foo_foo_base") == "-base"
|
||||
assert ids.get("lib_foo_foo_bar") == "-bar"
|
||||
assert ("lib_foo_foo", "method", "lib_foo_foo_bar") in _edges(result)
|
||||
assert ("lib_nsstring_nsstring", "method", "lib_nsstring_nsstring_baz") in _edges(result)
|
||||
|
||||
|
||||
def test_two_checkouts_give_the_same_graph(tmp_path: Path):
|
||||
one, two = tmp_path / "one" / "proj", tmp_path / "two" / "proj"
|
||||
a = _extract(one, _project(one), tmp_path / "cache-one")
|
||||
b = _extract(two, _project(two), tmp_path / "cache-two")
|
||||
assert {n["id"] for n in a["nodes"]} == {n["id"] for n in b["nodes"]}
|
||||
assert _edges(a) == _edges(b)
|
||||
|
||||
|
||||
def test_relative_input_paths_give_the_same_ids(tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "proj"
|
||||
absolute = _extract(root, _project(root), tmp_path / "cache-abs")
|
||||
monkeypatch.chdir(root)
|
||||
relative = _extract(Path("."), sorted(Path(rel) for rel in _FILES), tmp_path / "cache-rel")
|
||||
assert {n["id"] for n in absolute["nodes"]} == {n["id"] for n in relative["nodes"]}
|
||||
assert _edges(absolute) == _edges(relative)
|
||||
Reference in new issue
Block a user