mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
explain and query exited 0 when no node matched, the same status as an
answer, and path exited 1 for an ambiguous endpoint, a missing one and a
missing graph file alike. A script or an agent could not tell a refusal
from an answer without parsing the text.
explain, path and query now exit 3 when a name is ambiguous (the
candidates are listed) and 4 when no node matches. Messages and output
streams are unchanged; "no path found" between two existing nodes is
still an answer. The codes are documented in --help and the README.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 13dc2400e1)
121 lines
4.8 KiB
Python
121 lines
4.8 KiB
Python
"""`explain` / get_node refuse a name several nodes of ONE file carry, as `path` does.
|
|
|
|
The cross-file case was already refused; within one file, `_find_node`'s winning
|
|
tier was split by graph iteration order and one node presented as the answer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import networkx as nx
|
|
import pytest
|
|
|
|
import graphify.__main__ as mainmod
|
|
from graphify.cli import EXIT_AMBIGUOUS
|
|
from graphify.serve import _resolve_single_node
|
|
|
|
|
|
_NODES = [
|
|
("src_x", "x.ts", "src/x.ts", "L1"),
|
|
("src_x_run", "run()", "src/x.ts", "L3"),
|
|
("src_x_alphaclient", "AlphaClient", "src/x.ts", "L5"),
|
|
("src_x_alphaclient_nonce", ".nonce()", "src/x.ts", "L6"),
|
|
("src_x_betaclient", "BetaClient", "src/x.ts", "L9"),
|
|
("src_x_betaclient_nonce", ".nonce()", "src/x.ts", "L10"),
|
|
# Sourceless stubs: two files each import the same external interface.
|
|
("tests_a_php_entitymanagerinterface", "EntityManagerInterface", "", ""),
|
|
("tests_b_php_entitymanagerinterface", "EntityManagerInterface", "", ""),
|
|
]
|
|
_LINKS = [
|
|
("src_x", "src_x_run", "contains"),
|
|
("src_x_run", "src_x_alphaclient", "calls"),
|
|
("src_x_run", "src_x_betaclient", "calls"),
|
|
("src_x_alphaclient", "src_x_alphaclient_nonce", "method"),
|
|
("src_x_betaclient", "src_x_betaclient_nonce", "method"),
|
|
]
|
|
|
|
|
|
def _graph_data(reverse: bool = False) -> dict:
|
|
nodes = [
|
|
{"id": i, "label": lbl, "source_file": sf, "source_location": loc}
|
|
for i, lbl, sf, loc in _NODES
|
|
]
|
|
return {
|
|
"directed": False, "multigraph": False, "graph": {},
|
|
"nodes": list(reversed(nodes)) if reverse else nodes,
|
|
"links": [
|
|
{"source": a, "target": b, "relation": rel, "confidence": "EXTRACTED"}
|
|
for a, b, rel in _LINKS
|
|
],
|
|
}
|
|
|
|
|
|
def _write(tmp_path, reverse: bool = False):
|
|
p = tmp_path / ("graph_rev.json" if reverse else "graph.json")
|
|
p.write_text(json.dumps(_graph_data(reverse)))
|
|
return p
|
|
|
|
|
|
def _explain(monkeypatch, capsys, graph_path, label):
|
|
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
|
|
monkeypatch.setattr(mainmod.sys, "argv",
|
|
["graphify", "explain", label, "--graph", str(graph_path)])
|
|
try:
|
|
mainmod.main()
|
|
except SystemExit as exc:
|
|
return capsys.readouterr().out, exc.code
|
|
return capsys.readouterr().out, None
|
|
|
|
|
|
@pytest.mark.parametrize("label", [".nonce()", "src/x.ts::.nonce()"])
|
|
def test_explain_refuses_same_file_twins(monkeypatch, tmp_path, capsys, label):
|
|
out, code = _explain(monkeypatch, capsys, _write(tmp_path), label)
|
|
assert f"Ambiguous: '{label}' matches 2 nodes in src/x.ts." in out
|
|
assert "src_x_alphaclient_nonce" in out
|
|
assert "src_x_betaclient_nonce" in out
|
|
assert "Node: .nonce()" not in out
|
|
assert code == EXIT_AMBIGUOUS
|
|
|
|
|
|
def test_explain_refuses_sourceless_stubs(monkeypatch, tmp_path, capsys):
|
|
out, code = _explain(monkeypatch, capsys, _write(tmp_path), "EntityManagerInterface")
|
|
assert out.startswith("Ambiguous: 'EntityManagerInterface' matches 2 nodes")
|
|
assert "tests_a_php_entitymanagerinterface" in out
|
|
assert "tests_b_php_entitymanagerinterface" in out
|
|
assert "Node: EntityManagerInterface" not in out
|
|
assert code == EXIT_AMBIGUOUS
|
|
|
|
|
|
def test_explain_refusal_does_not_depend_on_node_order(monkeypatch, tmp_path, capsys):
|
|
forward, _ = _explain(monkeypatch, capsys, _write(tmp_path), ".nonce()")
|
|
reverse, _ = _explain(monkeypatch, capsys, _write(tmp_path, reverse=True), ".nonce()")
|
|
ids = lambda out: sorted(l.strip() for l in out.splitlines() if l.strip().startswith("id:"))
|
|
assert ids(forward) == ids(reverse) == [
|
|
"id: src_x_alphaclient_nonce", "id: src_x_betaclient_nonce",
|
|
]
|
|
|
|
|
|
def test_explain_node_id_still_names_one_twin(monkeypatch, tmp_path, capsys):
|
|
out, code = _explain(monkeypatch, capsys, _write(tmp_path), "src_x_betaclient_nonce")
|
|
assert "ID: src_x_betaclient_nonce" in out
|
|
assert "Ambiguous" not in out
|
|
assert code is None
|
|
|
|
|
|
def test_explain_file_path_still_resolves_to_the_file_node(monkeypatch, tmp_path, capsys):
|
|
"""A file path matches the file node and its members: precedence, not a tie."""
|
|
out, code = _explain(monkeypatch, capsys, _write(tmp_path), "src/x.ts")
|
|
assert "ID: src_x\n" in out
|
|
assert "Ambiguous" not in out
|
|
assert code is None
|
|
|
|
|
|
def test_get_node_resolver_refuses_same_file_twins():
|
|
"""get_node / get_neighbors share `_resolve_single_node` with the same refusal."""
|
|
G = nx.node_link_graph({**_graph_data(), "directed": True}, edges="links")
|
|
nid, err = _resolve_single_node(G, ".nonce()")
|
|
assert nid is None
|
|
assert err is not None and err.startswith("Ambiguous: '.nonce()' matches 2 nodes in src/x.ts.")
|
|
nid, err = _resolve_single_node(G, "src_x_alphaclient_nonce")
|
|
assert (nid, err) == ("src_x_alphaclient_nonce", None)
|