mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-25 08:11:54 +00:00
The ambiguous-name refusal added in #4517 routed through _resolve_single_node, whose winning tier can be a prefix or substring match, so a bare search keyword that merely substring-matched several labels was wrongly refused: `query "client"` returned EXIT 3 because it is a substring of `AlphaClient`/`BetaClient`. That narrowed query's keyword-search semantics. Adds _query_name_refusal, which refuses only when the term EXACTLY names several nodes (the source_exact/exact tiers) -- #4511's `.nonce()` case -- and otherwise falls through to scoring. The `query` and `--seed` paths use it; explain/path/ get_node keep _resolve_single_node, whose prefix/substring tiers are correct for a direct node lookup. Adds a regression test that a substring keyword still searches. Co-Authored-By: shivampal46011 <shivampal46011@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
6.5 KiB
Python
162 lines
6.5 KiB
Python
"""`explain`, `path` and `query` tell a script apart an answer, an ambiguous name
|
|
and a name that matches nothing, through the exit code alone.
|
|
|
|
`explain` used to exit 0 on a miss, the same as a successful answer, and the
|
|
ambiguity and miss refusals of `path` shared exit 1 with every other error.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
import graphify.__main__ as mainmod
|
|
from graphify.cli import EXIT_AMBIGUOUS, EXIT_NOT_FOUND
|
|
|
|
|
|
def _write_graph(tmp_path):
|
|
nodes = [
|
|
("src_a_alphaclient", "AlphaClient", "src/a.ts", "L1"),
|
|
("src_a_alphaclient_nonce", ".nonce()", "src/a.ts", "L2"),
|
|
("src_b_betaclient", "BetaClient", "src/b.ts", "L1"),
|
|
("src_b_betaclient_nonce", ".nonce()", "src/b.ts", "L2"),
|
|
("src_c_run", "run()", "src/c.ts", "L1"),
|
|
("src_d_island", "island()", "src/d.ts", "L1"),
|
|
]
|
|
links = [
|
|
("src_c_run", "src_b_betaclient", "calls"),
|
|
("src_b_betaclient", "src_b_betaclient_nonce", "method"),
|
|
("src_a_alphaclient", "src_a_alphaclient_nonce", "method"),
|
|
]
|
|
p = tmp_path / "graph.json"
|
|
p.write_text(json.dumps({
|
|
"directed": False, "multigraph": False, "graph": {},
|
|
"nodes": [
|
|
{"id": i, "label": lbl, "source_file": sf, "source_location": loc}
|
|
for i, lbl, sf, loc in nodes
|
|
],
|
|
"links": [
|
|
{"source": a, "target": b, "relation": rel, "confidence": "EXTRACTED"}
|
|
for a, b, rel in links
|
|
],
|
|
}))
|
|
return p
|
|
|
|
|
|
def _run(monkeypatch, capsys, *argv):
|
|
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
|
|
monkeypatch.setattr(mainmod.sys, "argv", ["graphify", *argv])
|
|
try:
|
|
mainmod.main()
|
|
except SystemExit as exc:
|
|
captured = capsys.readouterr()
|
|
return exc.code, captured.out + captured.err
|
|
captured = capsys.readouterr()
|
|
return None, captured.out + captured.err
|
|
|
|
|
|
def test_exit_codes_are_distinct_from_success_and_from_each_other():
|
|
assert len({0, 1, EXIT_AMBIGUOUS, EXIT_NOT_FOUND}) == 4
|
|
|
|
|
|
def test_explain_answer_exits_zero(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys, "explain", "run()", "--graph", str(_write_graph(tmp_path)))
|
|
assert code is None
|
|
assert "ID: src_c_run" in out
|
|
|
|
|
|
def test_explain_ambiguous_exits_ambiguous(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys, "explain", ".nonce()", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_AMBIGUOUS
|
|
assert "Ambiguous: '.nonce()' matches 2 nodes in different files." in out
|
|
|
|
|
|
@pytest.mark.parametrize("label", ["NoSuchSymbolXyz", "src/a.ts::NotThere"])
|
|
def test_explain_miss_exits_not_found(monkeypatch, tmp_path, capsys, label):
|
|
code, out = _run(monkeypatch, capsys, "explain", label, "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_NOT_FOUND
|
|
assert f"No node matching '{label}' found." in out
|
|
|
|
|
|
def test_path_answer_exits_zero(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys,
|
|
"path", "run()", "src/b.ts::.nonce()", "--graph", str(_write_graph(tmp_path)))
|
|
assert code is None
|
|
assert "Shortest path (2 hops):" in out
|
|
|
|
|
|
def test_path_without_route_is_an_answer(monkeypatch, tmp_path, capsys):
|
|
"""Both endpoints exist and no route joins them: that is the answer, not a miss."""
|
|
code, out = _run(monkeypatch, capsys,
|
|
"path", "run()", "island()", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == 0
|
|
assert "No directed path found" in out
|
|
|
|
|
|
def test_path_ambiguous_endpoint_exits_ambiguous(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys,
|
|
"path", "run()", ".nonce()", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_AMBIGUOUS
|
|
assert "Ambiguous: '.nonce()'" in out
|
|
|
|
|
|
def test_path_same_node_both_ends_exits_ambiguous(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys,
|
|
"path", "run()", "src_c_run", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_AMBIGUOUS
|
|
assert "both resolved to the same node" in out
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["NoSuchSymbolXyz", "src/a.ts::NotThere"])
|
|
def test_path_missing_endpoint_exits_not_found(monkeypatch, tmp_path, capsys, endpoint):
|
|
code, out = _run(monkeypatch, capsys,
|
|
"path", "run()", endpoint, "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_NOT_FOUND
|
|
assert "Shortest path" not in out
|
|
|
|
|
|
def test_query_answer_exits_zero(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys, "query", "run", "--graph", str(_write_graph(tmp_path)))
|
|
assert code is None
|
|
assert "NODE run()" in out
|
|
|
|
|
|
def test_query_without_any_match_exits_not_found(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys,
|
|
"query", "zzqqxx", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_NOT_FOUND
|
|
assert "No matching nodes found." in out
|
|
|
|
|
|
def test_query_ambiguous_name_exits_ambiguous(monkeypatch, tmp_path, capsys):
|
|
"""A bare name shared by nodes in different files is refused, not guessed (#4511)."""
|
|
code, out = _run(monkeypatch, capsys, "query", ".nonce()", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_AMBIGUOUS
|
|
assert "Ambiguous: '.nonce()' matches 2 nodes in different files." in out
|
|
assert "NODE " not in out
|
|
|
|
|
|
def test_query_ambiguous_seed_exits_ambiguous(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys,
|
|
"query", "anything", "--seed", ".nonce()", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_AMBIGUOUS
|
|
assert "Ambiguous: '.nonce()'" in out
|
|
|
|
|
|
def test_query_missing_seed_exits_not_found(monkeypatch, tmp_path, capsys):
|
|
code, out = _run(monkeypatch, capsys,
|
|
"query", "anything", "--seed", "NoSuchSymbolXyz", "--graph", str(_write_graph(tmp_path)))
|
|
assert code == EXIT_NOT_FOUND
|
|
assert "No unique node match for --seed: NoSuchSymbolXyz" in out
|
|
|
|
|
|
def test_query_substring_keyword_still_searches_not_refused(monkeypatch, tmp_path, capsys):
|
|
"""A bare keyword that only prefix/substring-matches labels (`client` ->
|
|
`AlphaClient`, `BetaClient`) is an exploratory search, not an ambiguous node
|
|
name, so query scores it instead of refusing (#4517). Only an EXACT-tier tie
|
|
(like `.nonce()`) refuses."""
|
|
code, out = _run(monkeypatch, capsys, "query", "client", "--graph", str(_write_graph(tmp_path)))
|
|
assert code != EXIT_AMBIGUOUS
|
|
assert "Ambiguous:" not in out
|
|
assert "Client" in out # it actually searched and surfaced a client node
|