From cd05c0738e1ba965a1383929a74aff5be6ad9434 Mon Sep 17 00:00:00 2001 From: Rohit Behera <126186063+r0h1tb@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:01:46 +0530 Subject: [PATCH] fix(cli): warn when a symbol name is ambiguous. Fixes #64 find_definition returns every symbol matching a name and the commands acted on defs[0], so an ambiguous name was answered for a symbol the user did not choose -- and nothing in the output said which one. An empty result then reads as "this function has no blocks" rather than "I looked at the wrong function". $ ast-rag blocks main No blocks found. while another `main` in the same repo had 45 blocks. The issue reported this for `blocks`. It was six sites: callers, call-graph, symbol-impact, blocks, summarize, and the sandbox find_callers tool. All now route through one helper. $ ast-rag blocks main ambiguous: 'main' matched 3 symbols (3 python). Reporting on embedding_server.main. Re-run with a qualified name to pick another, for example server.main, watcher_service.main No blocks found. Per the discussion on the issue the note leads with "ambiguous" so an agent reading the output hits it first, carries a per-language breakdown, and uses no exclamation mark. Behaviour is otherwise unchanged: the unambiguous case stays a single command and defs[0] is still used, so nothing that worked before now fails. Left out: the --filter idea from the same comment. That thread carries an open "TODO research about needed filter params", so it wants a decision on the filter vocabulary first rather than a guess here. --- ast_rag/cli.py | 56 ++++++++++++++++++++++++ tests/test_ambiguous_symbols.py | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 tests/test_ambiguous_symbols.py diff --git a/ast_rag/cli.py b/ast_rag/cli.py index 0346221..b28c7a7 100644 --- a/ast_rag/cli.py +++ b/ast_rag/cli.py @@ -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 # --------------------------------------------------------------------------- @@ -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]...") @@ -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"): @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/tests/test_ambiguous_symbols.py b/tests/test_ambiguous_symbols.py new file mode 100644 index 0000000..0e06b28 --- /dev/null +++ b/tests/test_ambiguous_symbols.py @@ -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