diff --git a/code_review_graph/tools/query.py b/code_review_graph/tools/query.py index d85e1335..9fac54a2 100644 --- a/code_review_graph/tools/query.py +++ b/code_review_graph/tools/query.py @@ -373,10 +373,17 @@ def add_result(result: dict[str, Any], edge: Any | None = None) -> None: } if not node and pattern not in ("consumers_of", "file_summary"): - return { + # This branch, not the empty-result path below, is where an + # unresolved target actually lands for most patterns, so the + # not-indexed marker has to be attached here too. + unresolved: dict[str, Any] = { "status": "not_found", "summary": f"No node found matching '{target}'.", } + unresolved_note = empty_query_confidence(store, root, pattern, target, None) + if unresolved_note: + unresolved["confidence"] = unresolved_note + return unresolved qn = node.qualified_name if node else target diff --git a/code_review_graph/uncertainty.py b/code_review_graph/uncertainty.py index 91b49e6c..04fbff47 100644 --- a/code_review_graph/uncertainty.py +++ b/code_review_graph/uncertainty.py @@ -245,6 +245,19 @@ def not_indexed_note(target: str) -> str: ) +def unresolved_stale_note(target: str) -> str: + """Say the target is missing from a graph that predates HEAD. + + A stale graph explains the miss and has a remedy, so it outranks the + flat "not indexed" wording, which reads like a permanent limitation. + """ + return _interpolated_target( + "graph is stale: no node matching '", + target, + "'; the graph predates HEAD, so run `code-review-graph update` first", + ) + + def _confirmed_note(target: str, current: bool) -> str: """Say the zero is a real absence, so the agent can stop searching. @@ -343,6 +356,14 @@ def empty_query_confidence( """ try: if node is None: + if store.get_stats().total_nodes == 0: + return _bounded( + "graph is empty: nothing is indexed, so this 0 says " + "nothing about the code; run `code-review-graph build`" + ) + stale, _unused = _staleness(store, root, None) + if stale: + return _bounded(unresolved_stale_note(target)) return _bounded(not_indexed_note(target)) stale, current = _staleness(store, root, getattr(node, "file_path", None)) diff --git a/tests/test_uncertainty.py b/tests/test_uncertainty.py index 5d75efec..e84066ed 100644 --- a/tests/test_uncertainty.py +++ b/tests/test_uncertainty.py @@ -466,3 +466,90 @@ def test_direct_call_returns_none_on_failure(repo, monkeypatch): assert empty_query_confidence( store, repo, "callers_of", "whatever", None, ) is None + + +# --------------------------------------------------------------------------- +# The not_found branch +# +# For every pattern except consumers_of and file_summary, an unresolved target +# returns early with status "not_found" and never reaches the empty-result +# path. That branch is where an unindexed target actually lands in production, +# so the marker has to be attached there too. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "pattern", + ["callers_of", "callees_of", "imports_of", "tests_for", "inheritors_of"], +) +def test_not_found_response_carries_a_marker(repo, pattern): + """An unresolved target must never come back as a bare not_found.""" + result = query_graph( + pattern=pattern, target="NoSuchSymbol", repo_root=str(repo), + ) + + assert result["status"] == "not_found" + assert result["confidence"] + assert "NoSuchSymbol" in result["confidence"] + + +def test_unresolved_target_on_a_stale_graph_says_stale_not_unindexed(repo, monkeypatch): + """A stale graph explains the miss and has a remedy, so it must win. + + Calling it "not indexed" reads like a permanent limitation and sends the + agent looking for a parser gap that is not there. + """ + with _store(repo) as store: + store.set_metadata("git_head_sha", "0" * 40) + store.commit() + + monkeypatch.setattr(uncertainty, "_live_git_head", lambda root: "f" * 40) + result = query_graph( + pattern="callers_of", target="NoSuchSymbol", repo_root=str(repo), + ) + + assert "stale" in result["confidence"] + assert "update" in result["confidence"] + assert "not indexed" not in result["confidence"] + + +def test_unresolved_target_on_a_current_graph_says_not_indexed(repo, monkeypatch): + """With currency established, not-indexed is the honest answer.""" + with _store(repo) as store: + store.set_metadata("git_head_sha", "a" * 40) + store.commit() + + monkeypatch.setattr(uncertainty, "_live_git_head", lambda root: "a" * 40) + result = query_graph( + pattern="callers_of", target="NoSuchSymbol", repo_root=str(repo), + ) + + assert "not indexed" in result["confidence"] + assert "stale" not in result["confidence"] + + +def test_unresolved_target_on_an_empty_graph_says_build(tmp_path): + """Nothing indexed at all is a build problem, not a missing symbol.""" + root = (tmp_path / "empty").resolve() + (root / ".code-review-graph").mkdir(parents=True) + (root / ".git").mkdir() + GraphStore(root / ".code-review-graph" / "graph.db").close() + + result = query_graph( + pattern="callers_of", target="Anything", repo_root=str(root), + ) + + assert "graph is empty" in result["confidence"] + assert "build" in result["confidence"] + + +def test_resolved_target_with_results_still_has_no_marker(repo): + """The token-budget guarantee holds on the not_found-adjacent path too.""" + result = query_graph( + pattern="callers_of", + target=f"{(repo / 'auth.py').as_posix()}::login", + repo_root=str(repo), + ) + + assert result["result_count"] >= 1 + assert "confidence" not in result