Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions ast_rag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,56 @@ def _build_api(cfg: ProjectConfig) -> ASTRagAPI:
return ASTRagAPI(driver, embed)


def _lang_of(node) -> str:
"""Language as a plain string, whether it is an enum or already a str."""
lang = getattr(node, "lang", None)
return getattr(lang, "value", None) or str(lang or "unknown")


def _describe_ambiguity(name: str, defs: list) -> Optional[str]:
"""Describe a name that resolved to more than one symbol, else None.

``find_definition`` returns every match and the commands act on the first,
so without this the caller cannot tell that a choice was made at all -- an
empty result reads as "this symbol has nothing" rather than "I looked at
the wrong symbol". See #64.

Leads with "ambiguous" so an agent scanning the output hits it first, and
avoids an exclamation mark, per the discussion on the issue.
"""
if len(defs) < 2:
return None

counts: dict[str, int] = {}
for d in defs:
lang = _lang_of(d)
counts[lang] = counts.get(lang, 0) + 1
breakdown = ", ".join(
f"{n} {lang}" for lang, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
)

chosen = defs[0].qualified_name
alternatives = [d.qualified_name for d in defs[1:4] if d.qualified_name != chosen]
hint = ""
if alternatives:
hint = " Re-run with a qualified name to pick another, for example " + ", ".join(
alternatives
)

return (
f"ambiguous: '{name}' matched {len(defs)} symbols ({breakdown}). "
f"Reporting on {chosen}.{hint}"
)


def _warn_if_ambiguous(name: str, defs: list) -> Optional[str]:
"""Print the ambiguity note (if any) and return it for JSON consumers."""
note = _describe_ambiguity(name, defs)
if note:
console.print(f"[yellow]{note}[/yellow]")
return note


# ---------------------------------------------------------------------------
# init command
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -473,6 +523,7 @@ def callers(
console.print(f"[yellow]Symbol not found: {qualified_name}[/yellow]")
raise typer.Exit(1)

_warn_if_ambiguous(qualified_name, defs)
target = defs[0]
if humanize:
console.print(f"Finding callers of [bold]{target.qualified_name}[/bold]...")
Expand Down Expand Up @@ -562,6 +613,7 @@ def call_graph(
console.print(f"[red]Function '{name}' not found[/red]")
raise typer.Exit(1)

_warn_if_ambiguous(name, defs)
node = defs[0]

if direction in ("callers", "both"):
Expand Down Expand Up @@ -605,6 +657,7 @@ def symbol_impact(
console.print(f"[red]Symbol '{name}' not found[/red]")
raise typer.Exit(1)

_warn_if_ambiguous(name, defs)
node = defs[0]

# Gather all info
Expand Down Expand Up @@ -815,6 +868,7 @@ def run_query(query: dict) -> dict:
elif tool_name == "find_callers":
defs = api.find_definition(params["name"], lang=params.get("lang"))
if defs:
_warn_if_ambiguous(params["name"], defs)
results = api.find_callers(defs[0].id, max_depth=params.get("depth", 1))
returned_items = results
else:
Expand Down Expand Up @@ -1350,6 +1404,7 @@ def blocks(
function_id = function
function_name = function
else:
_warn_if_ambiguous(function, defs)
function_id = defs[0].id
function_name = defs[0].qualified_name

Expand Down Expand Up @@ -1567,6 +1622,7 @@ def summarize(
console.print(f"[red]Symbol not found: {qualified_name}[/red]")
raise typer.Exit(1)

_warn_if_ambiguous(qualified_name, defs)
node = defs[0]

# Check if node kind is summarizable
Expand Down
75 changes: 75 additions & 0 deletions tests/test_ambiguous_symbols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""An ambiguous name must say so instead of silently answering about one match.

``find_definition`` returns every symbol matching a name; six CLI sites took
``defs[0]`` and reported on that one alone. For names that are ambiguous by
nature -- ``main``, ``run``, ``parse``, ``__init__`` -- the answer is usually
about a symbol the user did not mean, and an empty result reads as "this
function has no blocks" rather than "I looked at the wrong function".

Reported in #64 against ``blocks``, where ``ast-rag blocks main`` printed "No
blocks found." while another ``main`` in the same repo had 45.

The wording follows the issue discussion: lead with "ambiguous" so an agent
reading the output hits it first, and no exclamation mark.
"""

from __future__ import annotations

from dataclasses import dataclass

from ast_rag.cli import _describe_ambiguity


@dataclass
class _Def:
"""Minimal stand-in for ASTNode: only the fields the note reads."""

qualified_name: str
lang: str
kind: str = "Function"


def test_single_match_is_not_ambiguous():
assert _describe_ambiguity("main", [_Def("test_phase2.main", "python")]) is None


def test_no_match_is_not_ambiguous():
assert _describe_ambiguity("main", []) is None


def test_note_leads_with_ambiguous_and_counts_matches():
defs = [
_Def("TestCallResolution.main", "java"),
_Def("test_phase2.main", "python"),
_Def("benchmark_hybrid.main", "python"),
]
note = _describe_ambiguity("main", defs)

assert note is not None
assert note.lower().startswith("ambiguous"), note
assert "!" not in note, f"issue asked for no exclamation mark: {note}"
assert "3" in note, note


def test_note_breaks_down_by_language():
defs = [
_Def("a.main", "python"),
_Def("b.main", "python"),
_Def("c.main", "cpp"),
]
note = _describe_ambiguity("main", defs)
assert "2 python" in note, note
assert "1 cpp" in note, note


def test_note_names_the_symbol_actually_used_and_an_alternative():
defs = [
_Def("TestCallResolution.main", "java"),
_Def("test_phase2.main", "python"),
]
note = _describe_ambiguity("main", defs)

# The whole point of #64: the user could not tell which symbol was chosen.
assert "TestCallResolution.main" in note, note
# ...and needs to know how to ask for the other one.
assert "test_phase2.main" in note, note
Loading