fix(ruby): preserve suffixed method node IDs (#3077)

normalize_id strips !/?/= from Ruby method names, so save and save! (or foo and foo=) minted
the same node id and the second was dropped as a seen_ids collision. Add a Ruby symbol
sanitizer (!->_bang, ?->_pred, =->_eq) applied to the id only (the label keeps the raw
spelling), and rekey method_index on the raw name so member-call resolution still matches.
Ids stay stable across incremental re-extraction.
This commit is contained in:
hopstreax
2026-08-25 18:10:43 +01:00
committed by safishamsi
parent ef00185bb2
commit b48005276e
6 changed files with 195 additions and 6 deletions
+141
View File
@@ -422,3 +422,144 @@ def test_rake_files_extract_and_resolve_like_rb(tmp_path):
calls = {(label.get(e["source"]), label.get(e["target"]))
for e in result["edges"] if e["relation"] == "calls"}
assert (".run()", ".tally()") in calls
def test_ruby_suffixed_methods_extraction_and_labels(tmp_path: Path) -> None:
"""#3077: def foo, def foo!, def foo?, and def foo=(val) in the same class
must all survive extraction with distinct IDs and raw labels."""
f = _write(tmp_path, "thing.rb", """\
class Thing
def foo; end
def foo!; end
def foo?; end
def foo=(val); end
end
""")
r = extract_ruby(f)
assert "error" not in r
method_nodes = [n for n in r["nodes"] if n["id"] != r["nodes"][0]["id"] and n.get("label") != "Thing"]
assert len(method_nodes) == 4, f"Expected 4 distinct method nodes, got {method_nodes}"
node_by_label = {n["label"]: n["id"] for n in r["nodes"]}
assert ".foo()" in node_by_label
assert ".foo!()" in node_by_label
assert ".foo?()" in node_by_label
assert ".foo=()" in node_by_label
assert node_by_label[".foo()"].endswith("_foo")
assert node_by_label[".foo!()"].endswith("_foo_bang")
assert node_by_label[".foo?()"].endswith("_foo_pred")
assert node_by_label[".foo=()"].endswith("_foo_eq")
# Verify all 4 method edges exist
method_edges = [e for e in r["edges"] if e.get("relation") == "method"]
assert len(method_edges) == 4
targets = {e["target"] for e in method_edges}
assert len(targets) == 4
def test_ruby_suffixed_singleton_methods_extraction(tmp_path: Path) -> None:
"""#3077: Singleton methods (def self.foo!) must use the same sanitizer."""
f = _write(tmp_path, "service.rb", """\
class Service
def self.run!; end
def self.valid?; end
end
""")
r = extract_ruby(f)
node_by_label = {n["label"]: n["id"] for n in r["nodes"]}
assert ".run!()" in node_by_label
assert ".valid?()" in node_by_label
assert node_by_label[".run!()"].endswith("_run_bang")
assert node_by_label[".valid?()"].endswith("_valid_pred")
def test_ruby_suffixed_toplevel_functions_extraction(tmp_path: Path) -> None:
"""#3077: Top-level functions (def parse!) must use the same sanitizer."""
f = _write(tmp_path, "utils.rb", """\
def parse!; end
def valid?; end
""")
r = extract_ruby(f)
node_by_label = {n["label"]: n["id"] for n in r["nodes"]}
assert "parse!()" in node_by_label
assert "valid?()" in node_by_label
assert node_by_label["parse!()"].endswith("_parse_bang")
assert node_by_label["valid?()"].endswith("_valid_pred")
def test_ruby_suffixed_methods_call_resolution(tmp_path: Path) -> None:
"""#3077: Calls to p.save and p.save! must resolve to different target nodes."""
acc_path = _write(tmp_path, "account.rb", """\
class Account
def save
1
end
def save!
2
end
def valid?
true
end
end
""")
client_path = _write(tmp_path, "client.rb", """\
def perform_save
a = Account.new
a.save
end
def perform_save_bang
a = Account.new
a.save!
end
def perform_valid_query
a = Account.new
a.valid?
end
""")
g = extract([acc_path, client_path], cache_root=tmp_path / ".cache", parallel=False)
node_by_id = {n["id"]: n for n in g["nodes"]}
calls_by_caller = {}
for e in g["edges"]:
if e.get("relation") == "calls":
caller_node = node_by_id.get(e["source"])
target_node = node_by_id.get(e["target"])
if caller_node and target_node:
calls_by_caller.setdefault(caller_node["label"], []).append(target_node["label"])
assert ".save()" in calls_by_caller.get("perform_save()", []), \
f"perform_save should call .save(), got {calls_by_caller.get('perform_save()')}"
assert ".save!()" in calls_by_caller.get("perform_save_bang()", []), \
f"perform_save_bang should call .save!(), got {calls_by_caller.get('perform_save_bang()')}"
assert ".valid?()" in calls_by_caller.get("perform_valid_query()", []), \
f"perform_valid_query should call .valid?(), got {calls_by_caller.get('perform_valid_query()')}"
def test_ruby_suffixed_methods_id_stability(tmp_path: Path) -> None:
"""#3077: ID of foo! must remain stable when foo is added later."""
f1 = _write(tmp_path, "model.rb", """\
class Model
def foo!; end
end
""")
r1 = extract_ruby(f1)
node1 = next(n for n in r1["nodes"] if n.get("label") == ".foo!()")
id1 = node1["id"]
assert id1.endswith("_foo_bang")
# Add def foo
f2 = _write(tmp_path, "model.rb", """\
class Model
def foo; end
def foo!; end
end
""")
r2 = extract_ruby(f2)
node2_bang = next(n for n in r2["nodes"] if n.get("label") == ".foo!()")
node2_plain = next(n for n in r2["nodes"] if n.get("label") == ".foo()")
assert node2_bang["id"] == id1, "foo!'s ID must remain unchanged when foo is added"
assert node2_plain["id"] != node2_bang["id"], "foo and foo! must have distinct IDs"