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:
madhusudanpatnaikandClaude Opus 5.5 committed 2026-10-21 06:27:07 +00:00
1 parent b90b9df794
commit 11fe66a3cf
2 files changed
+96 -1

No files matched your search

+15 -1
View File
@@ -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
+81
View File
@@ -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)