mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-22 03:41:30 +00:00
The Python collision pre-scan (#3302) recorded module-level functions and class methods but not module-level class names, so a class sharing an id with a function or another class after normalization still lost one declaration to first-wins add_node: - `class Session` / `def session()` (requests/sessions.py): session() is absent from the graph, and which one survives depends on declaration order - `class Foo` / `class FOO`, `class Pair` / `class _Pair` Record module-level classes in the same scope as functions and salt the class id with the existing _underscore_salted_nid rule. Methods of a salted class are minted under the salted id, so the pre-scan now keys their groups under the class's resolved id; keying them under the plain one would drop the existing `_get`/`get` protection inside that class. Same-file base-class references go through the same salt (as C# already does), so `class Sub(Session)` binds to the real class instead of minting a sourceless `Session` stub. The pre-scan also looks through decorated_definition, so `@dataclass class Session` and the `_x` / `@property x` method idiom are covered too. Ids change only for files that contain such a collision; cached AST results for them keep the collapsed ids until the AST cache schema is bumped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
427 lines
15 KiB
Python
427 lines
15 KiB
Python
"""Regression coverage for Python leading-underscore node-id collisions (#3302).
|
|
|
|
``ids.py:make_id`` strips leading/trailing underscores from every part before
|
|
normalizing, so ``_get_connection``/``get_connection`` (and more broadly any
|
|
name differing only by ``_``/``__``/dunder wrapping, e.g. ``x``/``_x``/``__x``/
|
|
``__x__``) mint the SAME node id. ``add_node`` then silently drops whichever
|
|
declaration is walked second, so a public method/function can be entirely
|
|
absent from the graph while its private-by-convention sibling occupies the
|
|
public name.
|
|
"""
|
|
from __future__ import annotations
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
from graphify.extract import extract_python
|
|
|
|
|
|
def _write_py(tmp_path: Path, code: str) -> Path:
|
|
p = tmp_path / "mod.py"
|
|
p.write_text(textwrap.dedent(code))
|
|
return p
|
|
|
|
|
|
def _rationale_free_nodes(result: dict) -> list[dict]:
|
|
return [n for n in result["nodes"] if n.get("file_type") != "rationale"]
|
|
|
|
|
|
def test_leading_underscore_method_collision_both_extracted(tmp_path: Path) -> None:
|
|
"""#3302's exact repro: both methods survive as distinct nodes."""
|
|
path = _write_py(tmp_path, '''
|
|
class Adapter:
|
|
def _get_connection(self, url):
|
|
return url
|
|
|
|
def get_connection(self, url):
|
|
return self._get_connection(url)
|
|
|
|
def unrelated(self, x):
|
|
return x
|
|
''')
|
|
result = extract_python(path)
|
|
labels = {n["label"] for n in _rationale_free_nodes(result)}
|
|
assert "._get_connection()" in labels, f"private method missing: {labels}"
|
|
assert ".get_connection()" in labels, f"public method missing (#3302): {labels}"
|
|
assert ".unrelated()" in labels
|
|
|
|
|
|
def test_leading_underscore_method_collision_ids_distinct(tmp_path: Path) -> None:
|
|
path = _write_py(tmp_path, '''
|
|
class Adapter:
|
|
def _get_connection(self, url):
|
|
return url
|
|
|
|
def get_connection(self, url):
|
|
return self._get_connection(url)
|
|
''')
|
|
result = extract_python(path)
|
|
by_label = {n["label"]: n["id"] for n in _rationale_free_nodes(result)}
|
|
assert by_label["._get_connection()"] != by_label[".get_connection()"]
|
|
|
|
|
|
def test_public_method_keeps_plain_id_private_sibling_is_salted(tmp_path: Path) -> None:
|
|
"""The public member's id must equal what it would be with no private sibling
|
|
at all -- an incremental rebuild that adds/removes the private sibling must
|
|
not re-point edges already targeting the public method."""
|
|
solo = _write_py(tmp_path, '''
|
|
class Adapter:
|
|
def get_connection(self, url):
|
|
return url
|
|
''')
|
|
solo_result = extract_python(solo)
|
|
solo_id = next(
|
|
n["id"] for n in _rationale_free_nodes(solo_result)
|
|
if n["label"] == ".get_connection()"
|
|
)
|
|
|
|
with_sibling = _write_py(tmp_path, '''
|
|
class Adapter:
|
|
def _get_connection(self, url):
|
|
return url
|
|
|
|
def get_connection(self, url):
|
|
return self._get_connection(url)
|
|
''')
|
|
result = extract_python(with_sibling)
|
|
nodes = _rationale_free_nodes(result)
|
|
public_id = next(n["id"] for n in nodes if n["label"] == ".get_connection()")
|
|
private_id = next(n["id"] for n in nodes if n["label"] == "._get_connection()")
|
|
|
|
assert public_id == solo_id, (
|
|
f"adding a private sibling moved the public method's id: {solo_id} -> {public_id}"
|
|
)
|
|
assert private_id != public_id
|
|
|
|
|
|
def test_call_edge_resolves_to_the_salted_private_method(tmp_path: Path) -> None:
|
|
"""`get_connection`'s call to `self._get_connection(...)` must bind to the
|
|
salted private-method node, not dangle or bind to the public one."""
|
|
path = _write_py(tmp_path, '''
|
|
class Adapter:
|
|
def _get_connection(self, url):
|
|
return url
|
|
|
|
def get_connection(self, url):
|
|
return self._get_connection(url)
|
|
''')
|
|
result = extract_python(path)
|
|
nodes = _rationale_free_nodes(result)
|
|
public_id = next(n["id"] for n in nodes if n["label"] == ".get_connection()")
|
|
private_id = next(n["id"] for n in nodes if n["label"] == "._get_connection()")
|
|
|
|
calls = [e for e in result["edges"] if e.get("relation") == "calls"]
|
|
assert (public_id, private_id) in {(e["source"], e["target"]) for e in calls}, (
|
|
f"no calls edge from get_connection to the salted _get_connection: {calls}"
|
|
)
|
|
|
|
|
|
def test_module_level_function_collision_both_extracted(tmp_path: Path) -> None:
|
|
"""Same bug, module-scoped (not inside a class)."""
|
|
path = _write_py(tmp_path, '''
|
|
def _helper():
|
|
return 1
|
|
|
|
def helper():
|
|
return _helper()
|
|
''')
|
|
result = extract_python(path)
|
|
labels = {n["label"] for n in _rationale_free_nodes(result)}
|
|
assert "_helper()" in labels
|
|
assert "helper()" in labels
|
|
by_label = {n["label"]: n["id"] for n in _rationale_free_nodes(result)}
|
|
assert by_label["_helper()"] != by_label["helper()"]
|
|
calls = {(e["source"], e["target"]) for e in result["edges"] if e.get("relation") == "calls"}
|
|
assert (by_label["helper()"], by_label["_helper()"]) in calls
|
|
|
|
|
|
def test_no_unique_public_member_salts_every_member(tmp_path: Path) -> None:
|
|
"""`_x`/`__x` collide with no fully-public name in the group -- both must be
|
|
salted (order-independent), not one arbitrarily kept plain."""
|
|
path = _write_py(tmp_path, '''
|
|
class C:
|
|
def _x(self):
|
|
return 1
|
|
|
|
def __x(self):
|
|
return 2
|
|
''')
|
|
result = extract_python(path)
|
|
nodes = _rationale_free_nodes(result)
|
|
labels = {n["label"] for n in nodes}
|
|
assert "._x()" in labels and ".__x()" in labels
|
|
ids = {n["label"]: n["id"] for n in nodes}
|
|
plain_class_scope_id = next(n["id"] for n in nodes if n["label"] == "C")
|
|
# Neither survivor kept the bare, unsalted `<class>_x` id.
|
|
for label in ("._x()", ".__x()"):
|
|
assert ids[label] != f"{plain_class_scope_id}_x", (
|
|
f"{label} kept the unsalted id despite no unique public member"
|
|
)
|
|
|
|
|
|
def test_dunder_and_plain_name_collision_both_extracted(tmp_path: Path) -> None:
|
|
"""`__x__`, `__x`, and `x` all strip to the same id -- the fully public `x`
|
|
must win and the dunder must still be extracted, salted."""
|
|
path = _write_py(tmp_path, '''
|
|
class C:
|
|
def __x__(self):
|
|
return 1
|
|
|
|
def x(self):
|
|
return 2
|
|
''')
|
|
result = extract_python(path)
|
|
nodes = _rationale_free_nodes(result)
|
|
labels = {n["label"] for n in nodes}
|
|
assert ".__x__()" in labels and ".x()" in labels
|
|
ids = {n["label"]: n["id"] for n in nodes}
|
|
assert ids[".__x__()"] != ids[".x()"]
|
|
|
|
|
|
def test_no_collision_ids_unaffected(tmp_path: Path) -> None:
|
|
"""A file with no underscore-only collisions must extract exactly as before
|
|
-- no unnecessary salting applied to unrelated names."""
|
|
path = _write_py(tmp_path, '''
|
|
class D:
|
|
def public_one(self):
|
|
return 1
|
|
|
|
def _private_two(self):
|
|
return 2
|
|
''')
|
|
result = extract_python(path)
|
|
nodes = _rationale_free_nodes(result)
|
|
ids = {n["label"]: n["id"] for n in nodes}
|
|
class_id = next(n["id"] for n in nodes if n["label"] == "D")
|
|
assert ids[".public_one()"] == f"{class_id}_public_one"
|
|
assert ids["._private_two()"] == f"{class_id}_private_two"
|
|
|
|
|
|
def test_collision_in_one_class_does_not_salt_unrelated_class(tmp_path: Path) -> None:
|
|
"""A `_foo`/`foo` collision inside class A must not touch an unrelated,
|
|
non-colliding `_foo` in class B (scope-keyed, not name-keyed)."""
|
|
path = _write_py(tmp_path, '''
|
|
class A:
|
|
def _foo(self):
|
|
return 1
|
|
|
|
def foo(self):
|
|
return self._foo()
|
|
|
|
class B:
|
|
def _foo(self):
|
|
return 3
|
|
''')
|
|
result = extract_python(path)
|
|
nodes = _rationale_free_nodes(result)
|
|
class_b_id = next(n["id"] for n in nodes if n["label"] == "B")
|
|
b_foo = next(n for n in nodes if n["id"].startswith(class_b_id) and n["label"] == "._foo()")
|
|
assert b_foo["id"] == f"{class_b_id}_foo", (
|
|
"an unrelated class's non-colliding _foo was needlessly salted"
|
|
)
|
|
|
|
|
|
# --- Module-level classes join the collision map (#2810) -------------------
|
|
#
|
|
# The pre-scan above records module-level functions and class methods but not
|
|
# module-level class names, so a class colliding with a function or another
|
|
# class on one id (`Session` / `session()`, `Foo` / `FOO`, `Pair` / `_Pair`)
|
|
# still lost one declaration to first-wins `add_node`, and which one survived
|
|
# depended on declaration order.
|
|
|
|
|
|
def _labels_to_ids(result: dict) -> dict[str, str]:
|
|
return {n["label"]: n["id"] for n in _rationale_free_nodes(result)}
|
|
|
|
|
|
def test_class_and_function_case_collision_both_extracted(tmp_path: Path) -> None:
|
|
"""requests/sessions.py's shape: a class and its lowercase factory."""
|
|
path = _write_py(tmp_path, '''
|
|
class Session:
|
|
pass
|
|
|
|
def session():
|
|
return Session()
|
|
''')
|
|
ids = _labels_to_ids(extract_python(path))
|
|
assert "Session" in ids, f"class missing (#2810): {sorted(ids)}"
|
|
assert "session()" in ids, f"function missing (#2810): {sorted(ids)}"
|
|
assert ids["Session"] != ids["session()"]
|
|
|
|
|
|
def test_class_and_function_case_collision_is_order_independent(tmp_path: Path) -> None:
|
|
"""Function declared first must not change which declarations survive or
|
|
what ids they get."""
|
|
class_first = _labels_to_ids(extract_python(_write_py(tmp_path, '''
|
|
class Session:
|
|
pass
|
|
|
|
def session():
|
|
return Session()
|
|
''')))
|
|
func_first = _labels_to_ids(extract_python(_write_py(tmp_path, '''
|
|
def session():
|
|
return Session()
|
|
|
|
class Session:
|
|
pass
|
|
''')))
|
|
assert class_first["Session"] == func_first["Session"]
|
|
assert class_first["session()"] == func_first["session()"]
|
|
|
|
|
|
def test_class_and_class_case_collision_both_extracted(tmp_path: Path) -> None:
|
|
path = _write_py(tmp_path, '''
|
|
class Foo:
|
|
pass
|
|
|
|
class FOO:
|
|
pass
|
|
''')
|
|
ids = _labels_to_ids(extract_python(path))
|
|
assert "Foo" in ids and "FOO" in ids, f"a class was dropped: {sorted(ids)}"
|
|
assert ids["Foo"] != ids["FOO"]
|
|
|
|
|
|
def test_class_underscore_collision_public_class_keeps_plain_id(tmp_path: Path) -> None:
|
|
"""`Pair` / `_Pair`: same unique-public-member rule as functions -- the
|
|
public class keeps the id it has with no private sibling."""
|
|
solo_id = _labels_to_ids(extract_python(_write_py(tmp_path, '''
|
|
class Pair:
|
|
pass
|
|
''')))["Pair"]
|
|
ids = _labels_to_ids(extract_python(_write_py(tmp_path, '''
|
|
class _Pair:
|
|
pass
|
|
|
|
class Pair:
|
|
pass
|
|
''')))
|
|
assert "_Pair" in ids and "Pair" in ids
|
|
assert ids["Pair"] == solo_id
|
|
assert ids["_Pair"] != solo_id
|
|
|
|
|
|
def test_methods_of_a_salted_class_keep_underscore_protection(tmp_path: Path) -> None:
|
|
"""Once the class id is salted its methods are minted under the salted id,
|
|
so the pre-scan must key their collision groups under it too -- otherwise
|
|
`_get`/`get` inside the salted class collapse again."""
|
|
path = _write_py(tmp_path, '''
|
|
class Session:
|
|
def _get(self):
|
|
return 1
|
|
|
|
def get(self):
|
|
return self._get()
|
|
|
|
def session():
|
|
return Session()
|
|
''')
|
|
ids = _labels_to_ids(extract_python(path))
|
|
assert "._get()" in ids and ".get()" in ids, f"a method was dropped: {sorted(ids)}"
|
|
assert ids["._get()"] != ids[".get()"]
|
|
assert ids[".get()"].startswith(ids["Session"]), "method not scoped under its class"
|
|
|
|
|
|
def test_constructor_call_binds_to_the_salted_class(tmp_path: Path) -> None:
|
|
"""`session()`'s `Session()` call must reach the class node, not dangle or
|
|
bind back to `session()` itself."""
|
|
path = _write_py(tmp_path, '''
|
|
class Session:
|
|
pass
|
|
|
|
def session():
|
|
return Session()
|
|
''')
|
|
result = extract_python(path)
|
|
ids = _labels_to_ids(result)
|
|
pairs = {(e["source"], e["target"]) for e in result["edges"]}
|
|
assert (ids["session()"], ids["Session"]) in pairs, (
|
|
f"no edge from session() to the Session class: {result['edges']}"
|
|
)
|
|
assert (ids["session()"], ids["session()"]) not in pairs
|
|
|
|
|
|
def test_cross_file_import_resolves_to_the_salted_class(tmp_path: Path) -> None:
|
|
from graphify.build import build
|
|
from graphify.extract import extract
|
|
|
|
(tmp_path / "sessions.py").write_text(textwrap.dedent('''
|
|
class Session:
|
|
pass
|
|
|
|
def session():
|
|
return Session()
|
|
'''))
|
|
(tmp_path / "api.py").write_text(textwrap.dedent('''
|
|
from sessions import Session, session
|
|
|
|
def make():
|
|
return Session(), session()
|
|
'''))
|
|
G = build([extract(sorted(tmp_path.glob("*.py")), root=tmp_path, cache_root=tmp_path)])
|
|
label = {n: d.get("label") for n, d in G.nodes(data=True)}
|
|
targets = {
|
|
label.get(d.get("_tgt", v))
|
|
for u, v, d in G.edges(data=True)
|
|
if label.get(d.get("_src", u)) in ("api.py", "make()")
|
|
}
|
|
assert "Session" in targets, f"nothing from api.py reaches the class: {targets}"
|
|
assert "session()" in targets, f"nothing from api.py reaches the function: {targets}"
|
|
|
|
|
|
def test_same_file_subclass_inherits_from_the_salted_class(tmp_path: Path) -> None:
|
|
"""`class Sub(Session)` must point at the real (salted) class, not mint a
|
|
second sourceless `Session` stub under the plain id."""
|
|
path = _write_py(tmp_path, '''
|
|
class Session:
|
|
pass
|
|
|
|
def session():
|
|
return Session()
|
|
|
|
class Sub(Session):
|
|
pass
|
|
''')
|
|
result = extract_python(path)
|
|
nodes = _rationale_free_nodes(result)
|
|
assert [n["label"] for n in nodes].count("Session") == 1, (
|
|
f"a phantom Session stub was minted: {[(n['id'], n['label']) for n in nodes]}"
|
|
)
|
|
ids = _labels_to_ids(result)
|
|
inherits = {(e["source"], e["target"]) for e in result["edges"] if e.get("relation") == "inherits"}
|
|
assert (ids["Sub"], ids["Session"]) in inherits
|
|
|
|
|
|
def test_decorated_class_and_function_case_collision_both_extracted(tmp_path: Path) -> None:
|
|
"""`@dataclass class Session` is wrapped in a decorated_definition; the
|
|
pre-scan must see through it."""
|
|
path = _write_py(tmp_path, '''
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class Session:
|
|
x: int = 0
|
|
|
|
def session():
|
|
return Session()
|
|
''')
|
|
ids = _labels_to_ids(extract_python(path))
|
|
assert "Session" in ids and "session()" in ids, f"a declaration was dropped: {sorted(ids)}"
|
|
assert ids["Session"] != ids["session()"]
|
|
|
|
|
|
def test_decorated_method_underscore_collision_both_extracted(tmp_path: Path) -> None:
|
|
"""The `_x` / `@property x` idiom: a decorated method collides like a bare one."""
|
|
path = _write_py(tmp_path, '''
|
|
class C:
|
|
def _x(self):
|
|
return 1
|
|
|
|
@property
|
|
def x(self):
|
|
return self._x()
|
|
''')
|
|
ids = _labels_to_ids(extract_python(path))
|
|
assert "._x()" in ids and ".x()" in ids, f"a method was dropped: {sorted(ids)}"
|
|
assert ids["._x()"] != ids[".x()"]
|