test(kotlin): cover companion members + incremental persistence (#1698 follow-up)

#3598 resolves Kotlin object/class-qualified member calls across files. Adds
two regression tests the PR omitted:

- a `companion object` member call (`Service.create()`), pinning the #2565
  enclosing-class attribution the resolver relies on;
- an incremental-rebuild test routed through the real watch._rebuild_code, so
  it actually exercises the context builder's allow-list carrying _callable_class
  + method edges (a hand-fed resolution_context_nodes list would bypass exactly
  the path that silently regressed in sibling PRs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsiandClaude Opus 4.8 committed 2026-09-17 11:09:52 +01:00
1 parent 4afdfcbaaa
commit 4e811363a6
1 file changed
+80
+80
View File
@@ -577,3 +577,83 @@ def test_multiline_kotlin_unchanged(tmp_path, capsys):
if e["relation"] == "references" and e.get("context") == "field"}
assert (cart, inv) in field_refs
assert "syntax errors" not in capsys.readouterr().err
def test_kotlin_companion_member_call_resolves_across_files(tmp_path):
"""A `companion object` member is attributed to its enclosing class (#2565),
so `Service.create()` in another file resolves to the class's method — the
resolver relies on that attribution, so pin it directly (#1698)."""
r = _extract(tmp_path, {
"svc/Service.kt": (
"package com.demo.svc\n"
"\n"
"class Service {\n"
" companion object {\n"
" fun create() { }\n"
" }\n"
"}\n"
),
"app/Main.kt": (
"package com.demo.app\n"
"\n"
"import com.demo.svc.Service\n"
"\n"
"fun run() {\n"
" Service.create()\n"
"}\n"
),
})
run = _find(r, "run()")
create = _find(r, ".create()")
calls = _edges(r, "calls")
assert (run, create) in calls, \
"`Service.create()` must resolve to the companion member on the enclosing class"
edge = next(e for e in r["edges"] if e["relation"] == "calls"
and e["source"] == run and e["target"] == create)
assert edge["confidence"] == "EXTRACTED"
def test_kotlin_object_member_call_survives_incremental_rebuild(tmp_path):
"""The cross-file resolution must hold on the real `graphify update` / watch
path, where the unchanged receiver-type file arrives as a resolution-context
node. This exercises the actual watch context builder — it only works if the
`_callable_class` marker and `method` edges ride through its allow-list
(#1698). Routes through the real rebuild, not a hand-fed context list."""
import json
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
(corpus / "config").mkdir(parents=True)
(corpus / "app").mkdir(parents=True)
(corpus / "config" / "Config.kt").write_text(
"package com.demo.config\n\nobject Config {\n fun load() { }\n}\n",
encoding="utf-8",
)
app = corpus / "app" / "App.kt"
def _app(extra: str = "") -> str:
return ("package com.demo.app\n\nimport com.demo.config.Config\n\n"
"fun Start() {\n Config.load()\n" + extra + "}\n")
app.write_text(_app(), encoding="utf-8")
graph_path = corpus / "graphify-out" / "graph.json"
def _resolves() -> bool:
data = json.loads(graph_path.read_text(encoding="utf-8"))
start = next((n["id"] for n in data["nodes"] if n.get("label") == "Start()"), None)
load = next((n["id"] for n in data["nodes"] if n.get("label") == ".load()"), None)
if start is None or load is None:
return False
return any(e.get("relation") == "calls"
and {e.get("source"), e.get("target")} == {start, load}
for e in data["links"])
assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
assert _resolves(), "full build resolves the cross-file object member call"
# Change ONLY the caller: Config.kt is unchanged, so its object node + method
# edge are fed back as resolution context. The call must still resolve.
app.write_text(_app(" Config.load()\n"), encoding="utf-8")
assert _rebuild_code(corpus, changed_paths=[app], no_cluster=True,
acquire_lock=False) is True
assert _resolves(), "call stays resolved after an incremental rebuild"