diff --git a/CHANGELOG.md b/CHANGELOG.md index f1315532..771c738a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Added +- Empty results from `query_graph`, `get_impact_radius`, and + `semantic_search_nodes` now carry a one-sentence `confidence` field saying + *why* the count is zero: the target was never indexed, the graph is behind + the working tree, a known static-analysis gap applies to that language and + query (PHP container resolution and `include`/`require`, JS/TS callbacks and + route registration, npm-aliased imports, Java AOP and reflection, Go + structural interface satisfaction, C# DI, Python `getattr` and registry + decorators), or the zero is a verified real absence. The field is capped at + 140 characters and is emitted **only** when the result list is empty, so + responses that carry results are byte-identical to before. One honest + sentence is far cheaper than the wrong conclusion or the repo-wide grep a + bare zero provokes (#314, #819, #850, #851). - Added a Voyage AI embedding provider (`--provider voyage`, key from `VOYAGE_API_KEY`, opt-in request throttling via `CRG_VOYAGE_MIN_INTERVAL_SEC`). Embeddings are now persisted after each diff --git a/code_review_graph/tools/query.py b/code_review_graph/tools/query.py index 5fdbd1e3..d85e1335 100644 --- a/code_review_graph/tools/query.py +++ b/code_review_graph/tools/query.py @@ -15,6 +15,11 @@ from ..incremental import get_changed_files, get_db_path, get_staged_and_unstaged from ..parser import normalize_file_path from ..search import hybrid_search +from ..uncertainty import ( + empty_impact_confidence, + empty_query_confidence, + empty_search_confidence, +) from ._common import _BUILTIN_CALL_NAMES, _get_store, _resolve_graph_file_paths logger = logging.getLogger(__name__) @@ -180,6 +185,17 @@ def get_impact_radius( f" of {total_impacted} impacted nodes" ) + # "Nothing is impacted" and "nothing about these files is indexed" + # look identical to a reader without this marker. + confidence = None + if not impacted_dicts: + changed_language = next( + (n.language for n in result["changed_nodes"] if n.language), None, + ) + confidence = empty_impact_confidence( + store, root, changed_files, abs_files, changed_language, + ) + if detail_level == "minimal": impacted_count = len(impacted_dicts) if impacted_count > 20: @@ -200,10 +216,12 @@ def get_impact_radius( "truncated": truncated, "nodes_omitted": max(0, total_impacted - len(impacted_dicts)), } + if confidence: + minimal_response["confidence"] = confidence attach_context_savings(minimal_response, original_tokens=original_tokens) return minimal_response - response = { + response: dict[str, Any] = { "status": "ok", "summary": "\n".join(summary_parts), "changed_files": changed_files, @@ -215,6 +233,8 @@ def get_impact_radius( "total_impacted": total_impacted, "nodes_omitted": max(0, total_impacted - len(impacted_dicts)), } + if confidence: + response["confidence"] = confidence attach_context_savings(response, original_tokens=original_tokens) return response finally: @@ -656,6 +676,16 @@ def add_result(result: dict[str, Any], edge: Any | None = None) -> None: if results_omitted: summary += f" — showing {len(results)}, {results_omitted} omitted" + # A zero here is the dangerous direction: agents read it as "none + # exist" and either conclude wrongly or fall back to grepping the + # repository. One capped sentence prevents both, and is attached only + # when the result set is empty so non-empty responses are unchanged. + confidence = ( + empty_query_confidence(store, root, pattern, target, node) + if total_results == 0 + else None + ) + if detail_level == "minimal": minimal_results = [ { @@ -665,7 +695,7 @@ def add_result(result: dict[str, Any], edge: Any | None = None) -> None: } for r in results ] - return { + minimal_response: dict[str, Any] = { "status": "ok", "pattern": pattern, "target": target, @@ -675,8 +705,11 @@ def add_result(result: dict[str, Any], edge: Any | None = None) -> None: "results_omitted": results_omitted, "results": minimal_results, } + if confidence: + minimal_response["confidence"] = confidence + return minimal_response - return { + response: dict[str, Any] = { "status": "ok", "pattern": pattern, "target": target, @@ -687,6 +720,9 @@ def add_result(result: dict[str, Any], edge: Any | None = None) -> None: "results": results, "edges": edges_out, } + if confidence: + response["confidence"] = confidence + return response finally: store.close() @@ -738,6 +774,12 @@ def semantic_search_nodes( f" (kind={kind})" if kind else "" ) + # Zero hits can mean "no such symbol" or "never indexed"/"stale index"; + # only the marker distinguishes them. + confidence = ( + empty_search_confidence(store, root, query) if not results else None + ) + if detail_level == "minimal": minimal_results = [ { @@ -747,7 +789,7 @@ def semantic_search_nodes( } for r in results[:5] ] - return { + minimal_response: dict[str, Any] = { "status": "ok", "query": query, "search_mode": search_mode, @@ -756,6 +798,9 @@ def semantic_search_nodes( "result_count": len(results), "results_omitted": max(0, len(results) - len(minimal_results)), } + if confidence: + minimal_response["confidence"] = confidence + return minimal_response result: dict[str, object] = { "status": "ok", @@ -764,6 +809,8 @@ def semantic_search_nodes( "summary": summary, "results": results, } + if confidence: + result["confidence"] = confidence result["_hints"] = generate_hints( "semantic_search_nodes", result, get_session() ) diff --git a/code_review_graph/uncertainty.py b/code_review_graph/uncertainty.py new file mode 100644 index 00000000..91b49e6c --- /dev/null +++ b/code_review_graph/uncertainty.py @@ -0,0 +1,423 @@ +"""Honest uncertainty markers for empty graph results (#314, #819, #850, #851). + +A bare ``result_count: 0`` is ambiguous. It can mean "the code really has no +such relationship", or it can mean "this graph cannot see that relationship": +the target was never indexed, the graph is behind the working tree, or the +target's language has a known static-analysis blind spot. Reading agents take +the first meaning, and then either draw a wrong conclusion or abandon the +graph and grep the whole repository. + +One short sentence on the empty case is therefore a *token saving*, not a +cost: it replaces a multi-thousand-token fallback search with roughly thirty +tokens of honesty. To keep that trade favourable the marker is hard-capped at +``MAX_CONFIDENCE_CHARS`` and is attached only when a result list is empty, so +every response that carries results stays byte-identical to before. + +The language table below is data, not scattered conditionals. Every entry +describes a gap that is real in this codebase *today*; capabilities that have +since been implemented (Go struct/interface embedding, Kotlin imports, +dotted-stem JS/TS import resolution, Python keyword-argument callbacks) are +deliberately absent. +""" + +from __future__ import annotations + +import logging +import os +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from .graph import _sanitize_name + +if TYPE_CHECKING: # pragma: no cover - typing only + from .graph import GraphStore + +logger = logging.getLogger(__name__) + +# Hard token budget. Compact context is the whole point of this project, so +# an advisory sentence that grows past this is a regression, not a feature. +MAX_CONFIDENCE_CHARS = 140 + +# Synthetic pattern names for the entry points that take no ``pattern`` +# argument, so one table can serve every caller. +IMPACT_PATTERN = "impact_radius" + +_UPDATE_HINT = "run `code-review-graph update`" +_WHITESPACE = re.compile(r"\s+") + + +# --------------------------------------------------------------------------- +# Known static-analysis gaps, as data +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class LanguageGap: + """One verified blind spot in the parser, scoped to the queries it affects. + + ``patterns`` is what keeps the note honest: a container-resolution caveat + belongs on ``callers_of`` and ``tests_for``, and never on ``file_summary``, + whose empty result has nothing to do with call resolution. + """ + + languages: frozenset[str] + patterns: frozenset[str] + note: str + + +_JS_FAMILY = frozenset({"javascript", "typescript", "tsx"}) + +# Patterns answered from CALLS edges, where an unresolved dynamic call site +# can hide a real answer. ``references_to`` is deliberately excluded: it reads +# REFERENCES edges, which is exactly where an unresolved handoff does land. +_CALL_PATTERNS = frozenset({ + "callers_of", "callees_of", "tests_for", IMPACT_PATTERN, +}) +# Patterns answered from IMPORTS_FROM edges. +_IMPORT_PATTERNS = frozenset({"imports_of", "importers_of", IMPACT_PATTERN}) + +# Order matters: the first entry matching (language, pattern) is emitted. +LANGUAGE_GAPS: tuple[LanguageGap, ...] = ( + LanguageGap( + languages=frozenset({"php"}), + patterns=_IMPORT_PATTERNS, + note=( + "php include/require is not indexed as an import edge, so " + "importers can be missing here (#819)" + ), + ), + LanguageGap( + languages=frozenset({"php"}), + patterns=_CALL_PATTERNS, + note=( + "php container-resolved and constructor-injected calls are not " + "statically traced, so callers can be missing (#850, #851)" + ), + ), + LanguageGap( + languages=_JS_FAMILY, + patterns=frozenset({"handlers_of", "endpoints_for"}), + note=( + "js/ts route registration is not indexed and endpoint edges are " + "spring-only, so handlers can be missing" + ), + ), + LanguageGap( + languages=_JS_FAMILY, + patterns=_IMPORT_PATTERNS, + note=( + "npm-aliased import specifiers are not resolved, so importers " + "can be missing here (#343)" + ), + ), + LanguageGap( + languages=_JS_FAMILY, + patterns=_CALL_PATTERNS, + note=( + "js/ts callbacks land on REFERENCES not CALLS, and obj[name]() " + "is unresolved, so callers can be missing" + ), + ), + LanguageGap( + languages=frozenset({"java"}), + patterns=_CALL_PATTERNS, + note=( + "java aop advice and reflective invocation are not statically " + "traced, so callers can be missing here (#592)" + ), + ), + LanguageGap( + languages=frozenset({"go"}), + patterns=frozenset({"inheritors_of"}), + note=( + "go interface satisfaction is structural, never declared, so " + "implementers can be missing from inheritors_of" + ), + ), + LanguageGap( + languages=frozenset({"csharp"}), + patterns=_CALL_PATTERNS, + note=( + "c# di-container registrations are not statically traced, so " + "interface-typed callers can be missing here" + ), + ), + LanguageGap( + languages=frozenset({"python"}), + patterns=_CALL_PATTERNS, + note=( + "python getattr dispatch and decorator-based registration are " + "not statically traced, so callers can be missing here" + ), + ), +) + + +def gap_note(language: str | None, pattern: str) -> str | None: + """Return the first verified gap note for *language* under *pattern*.""" + if not language: + return None + normalized = language.strip().lower() + for gap in LANGUAGE_GAPS: + if normalized in gap.languages and pattern in gap.patterns: + return gap.note + return None + + +# --------------------------------------------------------------------------- +# Budgeting and sanitisation +# --------------------------------------------------------------------------- + + +def _clip(text: str, limit: int) -> str: + """Truncate *text* to *limit* characters, marking the cut.""" + if len(text) <= limit: + return text + return text[: max(1, limit - 1)] + "~" + + +def _clean(text: str) -> str: + """Make attacker-controlled source text safe to embed in one line. + + ``_sanitize_name`` is the project's established defence: it strips ASCII + control characters and caps length. It deliberately keeps tabs and + newlines, which would let a crafted node name break this single-line + advisory into fake extra lines, so they are collapsed here as well. + """ + cleaned = _sanitize_name(text, max_len=MAX_CONFIDENCE_CHARS) + return _WHITESPACE.sub(" ", cleaned).strip() + + +def _fragment(text: str, limit: int) -> str: + """Sanitized text, guaranteed to fit *limit* characters.""" + return _clip(_clean(text), max(1, limit)) + + +def _bounded(note: str) -> str: + """Final gate: nothing leaves this module over the budget.""" + return _fragment(note, MAX_CONFIDENCE_CHARS) + + +def _interpolated(prefix: str, value: str, suffix: str) -> str: + """Build ``prefix + value + suffix`` without letting *value* blow the cap.""" + budget = MAX_CONFIDENCE_CHARS - len(prefix) - len(suffix) + return f"{prefix}{_fragment(value, budget)}{suffix}" + + +def _target_fragment(target: str, limit: int) -> str: + """Fit a query target into *limit*, keeping the half that identifies it. + + Qualified names are ``path::symbol``, so a left-anchored truncation throws + away the symbol and keeps a directory prefix, the least useful half. Drop + to the bare symbol before resorting to clipping. The split runs on the raw + target because sanitising first can cut the ``::`` off a very long path. + """ + cleaned = _clean(target) + if len(cleaned) <= limit: + return cleaned + if "::" in target: + symbol = _clean(target.rsplit("::", 1)[-1]) + if 0 < len(symbol) <= limit: + return symbol + return _clip(cleaned, max(1, limit)) + + +def _interpolated_target(prefix: str, target: str, suffix: str) -> str: + """``_interpolated`` for query targets, which are qualified names.""" + budget = MAX_CONFIDENCE_CHARS - len(prefix) - len(suffix) + return f"{prefix}{_target_fragment(target, budget)}{suffix}" + + +# --------------------------------------------------------------------------- +# Individual markers +# --------------------------------------------------------------------------- + + +def not_indexed_note(target: str) -> str: + """Say that the graph never saw *target*, so the zero proves nothing.""" + return _interpolated_target( + "target not indexed: no node matching '", + target, + "', so this 0 is not evidence that none exist", + ) + + +def _confirmed_note(target: str, current: bool) -> str: + """Say the zero is a real absence, so the agent can stop searching. + + The strong wording is only used when currency was actually checked. When + it could not be (no VCS, no build metadata), the weaker sentence still + saves the fallback search without claiming something unverified. + """ + if current: + return _interpolated_target( + "'", target, + "' is indexed and the graph is current, so this 0 is a real absence", + ) + return _interpolated_target( + "'", target, + "' is indexed and no such edge is recorded; graph currency unverified", + ) + + +def _live_git_head(root: Path) -> str | None: + """Read the checked-out commit. + + Imported lazily: ``tools._common`` pulls in the tool modules, which import + this module, so a module-level import would be circular. The call costs a + subprocess, which is why staleness is only ever checked on an empty result + and never on the hot path. + """ + from .tools._common import _read_live_git_head + + return _read_live_git_head(root) + + +def _staleness( + store: GraphStore, root: Path, file_path: str | None, +) -> tuple[str | None, bool]: + """Return ``(stale_note, currency_verified)`` for the graph. + + Two independent signals, either of which can prove staleness: the build + commit versus the checked-out commit, and the target file's mtime versus + the build timestamp. The second matters because a commit match says + nothing about uncommitted edits. ``currency_verified`` is only ``True`` + when a check actually ran and passed, so callers never confuse "checked + and fresh" with "could not check". + """ + stored_sha = store.get_metadata("git_head_sha") + live_sha = _live_git_head(root) if stored_sha else None + if stored_sha and live_sha and live_sha != stored_sha: + return ( + "graph is stale: built at an older commit than HEAD, so this " + f"0 may be out of date; {_UPDATE_HINT}" + ), False + commit_verified = bool(stored_sha and live_sha) + + built_at_raw = store.get_metadata("last_updated") + if not built_at_raw or not file_path: + return None, commit_verified and not file_path + try: + # Graphs store absolute or repo-relative paths depending on how they + # were built, so anchor relative ones rather than stat-ing the CWD. + path = Path(file_path) + if not path.is_absolute(): + path = root / path + built_at = datetime.fromisoformat(built_at_raw) + mtime = os.stat(path).st_mtime + # Match the stored timestamp's awareness so the comparison is valid + # for both naive (legacy) and timezone-aware build records. + changed_at = datetime.fromtimestamp(mtime, tz=built_at.tzinfo) + except (OSError, OverflowError, TypeError, ValueError): + return None, False + if changed_at > built_at: + return _interpolated( + "graph is stale: ", + path.name, + f" changed after the last build; {_UPDATE_HINT}", + ), False + return None, commit_verified + + +# --------------------------------------------------------------------------- +# Public entry points, one per tool that can return an empty result +# --------------------------------------------------------------------------- + + +def empty_query_confidence( + store: GraphStore, + root: Path, + pattern: str, + target: str, + node: Any | None = None, +) -> str | None: + """Return the marker for an empty ``query_graph`` result, or ``None``. + + The priority order is deliberate: an unresolved target makes the zero + meaningless, a stale graph makes it untrustworthy, and a known language + gap makes it incomplete. Only when none of those hold is the zero worth + believing, and saying so is what stops an agent grepping anyway. + """ + try: + if node is None: + return _bounded(not_indexed_note(target)) + + stale, current = _staleness(store, root, getattr(node, "file_path", None)) + if stale: + return _bounded(stale) + + gap = gap_note(getattr(node, "language", None), pattern) + if gap: + return _bounded(gap) + + return _bounded(_confirmed_note(target, current)) + except Exception: + # An advisory marker must never turn a working tool call into an + # error. Degrading to "no marker" restores exactly today's response. + logger.debug("Could not compute empty-result confidence", exc_info=True) + return None + + +def empty_impact_confidence( + store: GraphStore, + root: Path, + changed_files: list[str], + resolved_files: list[str], + language: str | None = None, +) -> str | None: + """Return the marker for an empty ``get_impact_radius`` blast radius.""" + try: + if not resolved_files: + unknown = changed_files[0] if changed_files else "" + return _bounded(not_indexed_note(Path(unknown).name if unknown else "")) + + stale, current = _staleness(store, root, resolved_files[0]) + if stale: + return _bounded(stale) + + gap = gap_note(language, IMPACT_PATTERN) + if gap: + return _bounded(gap) + + if current: + return _bounded( + "changed files are indexed and the graph is current, so this " + "0 is a real absence" + ) + return _bounded( + "changed files are indexed and nothing depends on them; graph " + "currency unverified" + ) + except Exception: + logger.debug("Could not compute empty-impact confidence", exc_info=True) + return None + + +def empty_search_confidence( + store: GraphStore, + root: Path, + query: str, +) -> str | None: + """Return the marker for a ``semantic_search_nodes`` run with zero hits.""" + try: + 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, _current = _staleness(store, root, None) + if stale: + return _bounded(stale) + + return _bounded(_interpolated( + "no indexed node matches '", + query, + "'; search covers names, paths and signatures, not source text", + )) + except Exception: + logger.debug("Could not compute empty-search confidence", exc_info=True) + return None diff --git a/tests/test_uncertainty.py b/tests/test_uncertainty.py new file mode 100644 index 00000000..5d75efec --- /dev/null +++ b/tests/test_uncertainty.py @@ -0,0 +1,468 @@ +"""Tests for the empty-result ``confidence`` marker (#314, #819, #850, #851). + +The contract under test has two halves that pull against each other: + +* An empty result must never be reported as a bare zero, because agents read + that as "none exist" and either conclude wrongly or fall back to grepping. +* A non-empty result must be byte-identical to before, because this project's + whole value proposition is token efficiency. + +The second half is protected by explicit key-absence assertions; treat those +as budget guards, not incidental checks. +""" + +from __future__ import annotations + +import os +import tempfile +import time +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +import code_review_graph.uncertainty as uncertainty +from code_review_graph.graph import GraphStore +from code_review_graph.parser import EdgeInfo, NodeInfo +from code_review_graph.tools.query import ( + get_impact_radius, + query_graph, + semantic_search_nodes, +) +from code_review_graph.uncertainty import ( + LANGUAGE_GAPS, + MAX_CONFIDENCE_CHARS, + empty_query_confidence, + gap_note, +) + + +@pytest.fixture() +def repo(tmp_path_factory): + """A minimal project root with a graph that has real, resolvable nodes.""" + root = Path(tempfile.mkdtemp(dir=str(tmp_path_factory.mktemp("repos")))).resolve() + (root / ".git").mkdir() + (root / ".code-review-graph").mkdir() + + auth = (root / "auth.py") + auth.write_text("def login():\n pass\n", encoding="utf-8") + main = (root / "main.py") + main.write_text("import auth\n\n\ndef process():\n auth.login()\n", encoding="utf-8") + + db_path = root / ".code-review-graph" / "graph.db" + with GraphStore(db_path) as store: + for path in (auth, main): + store.upsert_node(NodeInfo( + kind="File", name=path.as_posix(), file_path=path.as_posix(), + line_start=1, line_end=5, language="python", + )) + store.upsert_node(NodeInfo( + kind="Function", name="login", file_path=auth.as_posix(), + line_start=1, line_end=2, language="python", + )) + store.upsert_node(NodeInfo( + kind="Function", name="process", file_path=main.as_posix(), + line_start=4, line_end=5, language="python", + )) + store.upsert_edge(EdgeInfo( + kind="CALLS", + source=f"{main.as_posix()}::process", + target=f"{auth.as_posix()}::login", + file_path=main.as_posix(), + line=5, + )) + store.commit() + return root + + +def _store(root: Path) -> GraphStore: + return GraphStore(root / ".code-review-graph" / "graph.db") + + +# --------------------------------------------------------------------------- +# The dangerous zero: a target the graph never saw +# --------------------------------------------------------------------------- + + +def test_unknown_target_is_marked_not_indexed(repo): + """file_summary on an unindexed path returns 0 — that 0 must be qualified.""" + result = query_graph( + pattern="file_summary", target="does_not_exist.py", repo_root=str(repo), + ) + + assert result["result_count"] == 0 + assert "not indexed" in result["confidence"] + assert "not evidence that none exist" in result["confidence"] + + +def test_unknown_target_marker_survives_minimal_detail_level(repo): + """The marker is short enough to belong in minimal mode too.""" + result = query_graph( + pattern="file_summary", target="does_not_exist.py", + repo_root=str(repo), detail_level="minimal", + ) + + assert result["result_count"] == 0 + assert "not indexed" in result["confidence"] + + +def test_unknown_config_key_is_marked_not_indexed(repo): + """consumers_of is the other pattern that resolves no node yet returns 0.""" + result = query_graph( + pattern="consumers_of", target="app.nothing.here", repo_root=str(repo), + ) + + assert result["result_count"] == 0 + assert "not indexed" in result["confidence"] + + +# --------------------------------------------------------------------------- +# The honest zero: an indexed target that genuinely has none +# --------------------------------------------------------------------------- + + +def test_genuinely_empty_result_gets_a_different_marker(repo): + """A real absence must not be labelled 'not indexed' — that would mislead.""" + auth = (repo / "auth.py").as_posix() + result = query_graph( + pattern="inheritors_of", target=f"{auth}::login", repo_root=str(repo), + ) + + assert result["result_count"] == 0 + confidence = result["confidence"] + assert "not indexed" not in confidence + assert "is indexed" in confidence + assert "login" in confidence + + +def test_nonempty_result_has_no_confidence_key(repo): + """Token budget guard: responses that carry results must be unchanged.""" + auth = (repo / "auth.py").as_posix() + result = query_graph( + pattern="callers_of", target=f"{auth}::login", repo_root=str(repo), + ) + + assert result["result_count"] == 1 + assert "confidence" not in result + + +def test_nonempty_minimal_result_has_no_confidence_key(repo): + auth = (repo / "auth.py").as_posix() + result = query_graph( + pattern="callers_of", target=f"{auth}::login", + repo_root=str(repo), detail_level="minimal", + ) + + assert result["result_count"] == 1 + assert "confidence" not in result + + +def test_nonempty_search_result_has_no_confidence_key(repo): + result = semantic_search_nodes(query="login", repo_root=str(repo)) + + assert result["results"] + assert "confidence" not in result + + +def test_builtin_skip_branch_is_left_alone(repo): + """The existing plain-language reason is the precedent, not a duplicate.""" + result = query_graph(pattern="callers_of", target="map", repo_root=str(repo)) + + assert result["result_count"] == 0 + assert "common builtin" in result["summary"] + assert "confidence" not in result + + +# --------------------------------------------------------------------------- +# Language gap table: per language and per pattern +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("language", "pattern", "expected"), + [ + ("php", "callers_of", "container-resolved"), + ("php", "importers_of", "include/require"), + ("javascript", "callers_of", "REFERENCES"), + ("typescript", "callers_of", "REFERENCES"), + ("tsx", "importers_of", "npm-aliased"), + ("typescript", "endpoints_for", "route registration"), + ("java", "callers_of", "aop advice"), + ("go", "inheritors_of", "structural"), + ("csharp", "tests_for", "di-container"), + ("python", "callers_of", "getattr"), + ], +) +def test_language_gap_table_fires_per_language_and_pattern(language, pattern, expected): + note = gap_note(language, pattern) + assert note is not None + assert expected in note + + +@pytest.mark.parametrize( + ("language", "pattern"), + [ + # A container-resolution caveat has nothing to do with listing a + # file's contents, so it must not leak onto file_summary. + ("php", "file_summary"), + ("csharp", "file_summary"), + # references_to reads REFERENCES edges, which is exactly where an + # unresolved JS/TS callback handoff does land. + ("javascript", "references_to"), + # Go's gap is interface satisfaction, not call resolution. + ("go", "callers_of"), + # Import gaps are language-specific, not universal. + ("java", "importers_of"), + ("python", "inheritors_of"), + ], +) +def test_language_gap_table_does_not_over_fire(language, pattern): + assert gap_note(language, pattern) is None + + +def test_gap_table_ignores_unknown_and_missing_languages(): + assert gap_note(None, "callers_of") is None + assert gap_note("", "callers_of") is None + assert gap_note("brainfuck", "callers_of") is None + + +def test_gap_notes_are_case_insensitive(): + assert gap_note("PHP", "callers_of") == gap_note("php", "callers_of") + + +def test_every_gap_note_fits_the_budget(): + for gap in LANGUAGE_GAPS: + assert len(gap.note) <= MAX_CONFIDENCE_CHARS, gap.note + + +def test_php_gap_reaches_a_real_query_response(repo): + """The table is wired, not just unit-tested in isolation.""" + service = repo / "Service.php" + service.write_text("