codecortex 0.26.0__tar.gz → 0.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. {codecortex-0.26.0/src/codecortex.egg-info → codecortex-0.27.0}/PKG-INFO +1 -1
  2. {codecortex-0.26.0 → codecortex-0.27.0/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. {codecortex-0.26.0 → codecortex-0.27.0}/src/codecortex.egg-info/SOURCES.txt +3 -0
  4. codecortex-0.27.0/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/changed_range.py +5 -1
  6. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/gateway.py +41 -2
  7. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_answer.py +315 -39
  8. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_changed.py +52 -1
  9. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_confidence.py +67 -6
  10. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_dispatch.py +50 -0
  11. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_edges.py +7 -1
  12. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_ops.py +5 -0
  13. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/provider.py +6 -4
  14. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/providers/graph.py +10 -0
  15. codecortex-0.27.0/src/codeintel/qualifier_scan.py +388 -0
  16. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/server.py +17 -6
  17. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_bench_graphify.py +11 -2
  18. codecortex-0.27.0/tests/test_bench_qualified.py +157 -0
  19. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_changed_range.py +175 -0
  20. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_dispatch_callers.py +105 -0
  21. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_docs_trust_claims.py +87 -0
  22. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_edge_confidence.py +656 -2
  23. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_gateway.py +66 -0
  24. codecortex-0.27.0/tests/test_qualifier_scan.py +596 -0
  25. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_summary_integrity.py +13 -1
  26. codecortex-0.26.0/src/codeintel/__init__.py +0 -1
  27. {codecortex-0.26.0 → codecortex-0.27.0}/LICENSE +0 -0
  28. {codecortex-0.26.0 → codecortex-0.27.0}/README.md +0 -0
  29. {codecortex-0.26.0 → codecortex-0.27.0}/pyproject.toml +0 -0
  30. {codecortex-0.26.0 → codecortex-0.27.0}/setup.cfg +0 -0
  31. {codecortex-0.26.0 → codecortex-0.27.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  32. {codecortex-0.26.0 → codecortex-0.27.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  33. {codecortex-0.26.0 → codecortex-0.27.0}/src/codecortex.egg-info/requires.txt +0 -0
  34. {codecortex-0.26.0 → codecortex-0.27.0}/src/codecortex.egg-info/top_level.txt +0 -0
  35. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/__main__.py +0 -0
  36. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/agent_prompt.py +0 -0
  37. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/auth.py +0 -0
  38. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/c4.py +0 -0
  39. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/c4_check.py +0 -0
  40. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/c4_layers.py +0 -0
  41. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/cache.py +0 -0
  42. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/__init__.py +0 -0
  43. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/_common.py +0 -0
  44. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/c4.py +0 -0
  45. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/doctor.py +0 -0
  46. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/gen_token.py +0 -0
  47. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/graph.py +0 -0
  48. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/index.py +0 -0
  49. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/install.py +0 -0
  50. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/map.py +0 -0
  51. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/prompt.py +0 -0
  52. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/query.py +0 -0
  53. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/reset.py +0 -0
  54. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/serve.py +0 -0
  55. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/serve_http.py +0 -0
  56. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/setup.py +0 -0
  57. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/status.py +0 -0
  58. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/commands/uninstall.py +0 -0
  59. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/config.py +0 -0
  60. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/containment.py +0 -0
  61. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/doctor.py +0 -0
  62. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/filelock.py +0 -0
  63. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_backend.py +0 -0
  64. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_render.py +0 -0
  65. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_resolution.py +0 -0
  66. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/graph_targets.py +0 -0
  67. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/grapher.py +0 -0
  68. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/http_server.py +0 -0
  69. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/indexer.py +0 -0
  70. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/injector.py +0 -0
  71. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/installer.py +0 -0
  72. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/lang_config.py +0 -0
  73. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/loc.py +0 -0
  74. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/logconfig.py +0 -0
  75. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/mapper.py +0 -0
  76. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/metrics.py +0 -0
  77. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/onboarding.py +0 -0
  78. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/outcome.py +0 -0
  79. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/paths.py +0 -0
  80. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/per_thread.py +0 -0
  81. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/policy.py +0 -0
  82. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/progress.py +0 -0
  83. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/providers/__init__.py +0 -0
  84. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/providers/lsp.py +0 -0
  85. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/providers/none.py +0 -0
  86. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/providers/semantic.py +0 -0
  87. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/py.typed +0 -0
  88. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/query_ops.py +0 -0
  89. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/redact.py +0 -0
  90. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/reindexer.py +0 -0
  91. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/reset.py +0 -0
  92. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/searcher.py +0 -0
  93. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/semantic_db.py +0 -0
  94. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/source_kind.py +0 -0
  95. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/symbol_diff.py +0 -0
  96. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/term.py +0 -0
  97. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/verify.py +0 -0
  98. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/viewer/__init__.py +0 -0
  99. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/viewer/graph_template.html +0 -0
  100. {codecortex-0.26.0 → codecortex-0.27.0}/src/codeintel/wire_text.py +0 -0
  101. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_agent_prompt.py +0 -0
  102. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_backend_failure_detail.py +0 -0
  103. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_backend_reaper.py +0 -0
  104. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_background_index_failure.py +0 -0
  105. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_bench_corpus_typed.py +0 -0
  106. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_bench_gate.py +0 -0
  107. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_bench_oracle.py +0 -0
  108. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_bench_oracle_ts.py +0 -0
  109. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_c4.py +0 -0
  110. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_c4_check.py +0 -0
  111. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_c4_layers.py +0 -0
  112. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_c4_model.py +0 -0
  113. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_c4_output.py +0 -0
  114. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_cache.py +0 -0
  115. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_chunking.py +0 -0
  116. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_cli_commands.py +0 -0
  117. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_cli_help.py +0 -0
  118. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_cli_install_dry_run.py +0 -0
  119. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_cli_install_offer_injection.py +0 -0
  120. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_cold_process.py +0 -0
  121. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_config.py +0 -0
  122. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_corpus.py +0 -0
  123. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_could_not_ask_semantic.py +0 -0
  124. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_docs_ci_claims.py +0 -0
  125. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_docs_deadcode_withdrawal.py +0 -0
  126. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_doctor.py +0 -0
  127. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_e2e.py +0 -0
  128. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_edge_endpoint_cap.py +0 -0
  129. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_engine_adoption.py +0 -0
  130. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_enterprise.py +0 -0
  131. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_graph_failure_population.py +0 -0
  132. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_graph_provider.py +0 -0
  133. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_graph_real.py +0 -0
  134. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_graph_stdin.py +0 -0
  135. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_grapher.py +0 -0
  136. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_hard_exit.py +0 -0
  137. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_hardening.py +0 -0
  138. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_http_auth.py +0 -0
  139. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_http_server.py +0 -0
  140. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_incompleteness.py +0 -0
  141. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_index_failure_reason.py +0 -0
  142. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_index_incompleteness.py +0 -0
  143. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_index_progress.py +0 -0
  144. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_indexer_binary.py +0 -0
  145. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_installer.py +0 -0
  146. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_integration.py +0 -0
  147. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_lang_config.py +0 -0
  148. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_loc_census.py +0 -0
  149. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_lsp_language_coverage.py +0 -0
  150. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_lsp_provider.py +0 -0
  151. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_lsp_real.py +0 -0
  152. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_mapper.py +0 -0
  153. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_mcp_handshake.py +0 -0
  154. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_mcp_server.py +0 -0
  155. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_missing_target.py +0 -0
  156. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_model_dimension.py +0 -0
  157. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_never_raise.py +0 -0
  158. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_null_reason_and_ripple.py +0 -0
  159. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_onboarding.py +0 -0
  160. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_per_thread.py +0 -0
  161. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_query_budget.py +0 -0
  162. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_rbac.py +0 -0
  163. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_redaction_boundary.py +0 -0
  164. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_reindex_lock.py +0 -0
  165. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_reindexer.py +0 -0
  166. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_rerank.py +0 -0
  167. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_reset.py +0 -0
  168. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_semantic_provider.py +0 -0
  169. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_source_kind.py +0 -0
  170. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_staleness.py +0 -0
  171. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_symbol_context.py +0 -0
  172. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_symbol_diff.py +0 -0
  173. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_term.py +0 -0
  174. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_treesitter.py +0 -0
  175. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_uninstall.py +0 -0
  176. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_upgrade_path.py +0 -0
  177. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_verify.py +0 -0
  178. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_verify_call.py +0 -0
  179. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_version_skew.py +0 -0
  180. {codecortex-0.26.0 → codecortex-0.27.0}/tests/test_wire_text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -49,6 +49,7 @@ src/codeintel/policy.py
49
49
  src/codeintel/progress.py
50
50
  src/codeintel/provider.py
51
51
  src/codeintel/py.typed
52
+ src/codeintel/qualifier_scan.py
52
53
  src/codeintel/query_ops.py
53
54
  src/codeintel/redact.py
54
55
  src/codeintel/reindexer.py
@@ -94,6 +95,7 @@ tests/test_bench_gate.py
94
95
  tests/test_bench_graphify.py
95
96
  tests/test_bench_oracle.py
96
97
  tests/test_bench_oracle_ts.py
98
+ tests/test_bench_qualified.py
97
99
  tests/test_c4.py
98
100
  tests/test_c4_check.py
99
101
  tests/test_c4_layers.py
@@ -151,6 +153,7 @@ tests/test_never_raise.py
151
153
  tests/test_null_reason_and_ripple.py
152
154
  tests/test_onboarding.py
153
155
  tests/test_per_thread.py
156
+ tests/test_qualifier_scan.py
154
157
  tests/test_query_budget.py
155
158
  tests/test_rbac.py
156
159
  tests/test_redaction_boundary.py
@@ -0,0 +1 @@
1
+ __version__ = "0.27.0"
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import os
30
30
  import re
31
+ import stat
31
32
  import subprocess
32
33
  import tempfile
33
34
  import threading
@@ -323,7 +324,10 @@ def read_new(root: str, path: str) -> str | None:
323
324
  if safe is None:
324
325
  return None
325
326
  try:
326
- if os.path.getsize(safe) > MAX_SOURCE_BYTES:
327
+ info = os.stat(safe)
328
+ # A regular file only. A FIFO planted at a tracked path has one link and a size of zero, passes
329
+ # containment, and blocks `open` forever — which would hang the whole answer.
330
+ if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_SOURCE_BYTES:
327
331
  return None
328
332
  with open(safe, "rb") as fh:
329
333
  return fh.read().decode("utf-8", "replace")
@@ -83,6 +83,41 @@ def _is_retryable(result: Result) -> bool:
83
83
  )
84
84
 
85
85
 
86
+ def _states_live_file_contents(result: Result | dict[str, Result]) -> bool:
87
+ """Whether the answer states something read from the files on disk as it was asked — the qualifier
88
+ scan's verdicts (`qualifier_seen` on a row, or the `qualifier_absent` / `qualifier_present`
89
+ counts).
90
+
91
+ The cache is keyed by the target string and the index's freshness generation, and neither moves
92
+ when a caller file starts or stops writing the qualifier — only a reindex does. Caching such an
93
+ answer would serve a verdict about the files as they WERE, in prose that says "as they are on
94
+ disk": the reason `changed` is never cached, met by a narrower population. An answer whose scan
95
+ judged nothing printed only the command — but if it was eligible and judged nothing because a
96
+ call stalled, the budget ran out or a lookup timed out (`qualifier_checked`), caching that would
97
+ keep the check switched off for the symbol until the next reindex, long after the filesystem
98
+ recovered. So an answer whose check was attempted at all is not cached either.
99
+ Walks the whole answer, because a fan-out answer nests each engine's envelope."""
100
+ def walk(node: object, depth: int) -> bool:
101
+ if depth > 8:
102
+ return False
103
+ if isinstance(node, dict):
104
+ evidence = node.get("evidence")
105
+ if isinstance(evidence, dict) and (evidence.get("qualifier_absent")
106
+ or evidence.get("qualifier_present")
107
+ or evidence.get("qualifier_checked")):
108
+ return True
109
+ rows = node.get("rows")
110
+ if isinstance(rows, list) and any(
111
+ isinstance(r, dict) and r.get("qualifier_seen") is not None for r in rows):
112
+ return True
113
+ return any(walk(v, depth + 1) for v in node.values() if isinstance(v, (dict, list)))
114
+ if isinstance(node, list):
115
+ return any(walk(v, depth + 1) for v in node)
116
+ return False
117
+
118
+ return walk(result, 0)
119
+
120
+
86
121
  class Gateway:
87
122
  def __init__(self, graph=None, lsp=None, semantic=None, policy: TieringPolicy | None = None,
88
123
  reindexer: Reindexer | None = None, oneshot: bool = False):
@@ -682,7 +717,10 @@ class Gateway:
682
717
  engines = ["graph", "lsp"] if engine_str == "both" else ["graph", "lsp", "semantic"]
683
718
  fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
684
719
  result = self._merge(fan_results, op_str, target_str, engine_str)
685
- if not uncacheable and not _is_retryable(result):
720
+ # The merged answer drops each engine's `rows` and `evidence` but keeps their prose,
721
+ # so whether it states what the files hold now is read from the engines' own answers.
722
+ if (not uncacheable and not _is_retryable(result)
723
+ and not _states_live_file_contents(fan_results)):
686
724
  self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
687
725
  return _mark_reindexing(result, reindexing)
688
726
 
@@ -721,7 +759,8 @@ class Gateway:
721
759
  result = self._cross_check_name_resolved(
722
760
  result, op_str, target_str, budget, project_root, was_auto)
723
761
 
724
- if not uncacheable and not _is_retryable(result):
762
+ if (not uncacheable and not _is_retryable(result)
763
+ and not _states_live_file_contents(result)):
725
764
  self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
726
765
  return _mark_reindexing(result, reindexing)
727
766
 
@@ -21,10 +21,12 @@ from __future__ import annotations
21
21
 
22
22
  from typing import TYPE_CHECKING, Any
23
23
 
24
+ from codeintel import qualifier_scan
24
25
  from codeintel.graph_confidence import (
25
26
  _EDGE_CONFIDENCE_FLOOR,
26
27
  _EDGE_CONFIDENCE_WEAK,
27
28
  _NAME_MATCHED,
29
+ _QUALIFIER_BYPASSES,
28
30
  _RESOLVED,
29
31
  _SAME_MODULE_FOREIGN,
30
32
  _UNSTATED,
@@ -33,6 +35,9 @@ from codeintel.graph_confidence import (
33
35
  _edge_confidence,
34
36
  _edge_strength,
35
37
  _evidence_class,
38
+ _is_own_receiver,
39
+ _own_receivers_of,
40
+ _qualifier_badge,
36
41
  _same_module_call,
37
42
  _why,
38
43
  )
@@ -51,10 +56,18 @@ from codeintel.graph_render import (
51
56
  _is_module_scope_node,
52
57
  _is_non_code,
53
58
  _lang_family,
59
+ _node_labels,
54
60
  _strip_project_prefix,
55
61
  )
56
62
  from codeintel.graph_targets import _SymbolTarget
57
63
 
64
+ # What the discriminating token IS, and the two are not interchangeable. The first names a program
65
+ # entity a caller must get hold of to reach the symbol; the second names a path a caller may reach
66
+ # through a re-export without ever spelling it. Only the first is strong enough to scan on — see
67
+ # `_scan_for_qualifier`.
68
+ _WHY_QUALIFIED = "the name it is qualified by"
69
+ _WHY_MODULE = "the module it is defined in"
70
+
58
71
 
59
72
  class AnswerRendering:
60
73
  """Row rendering, ambiguity disclosure and the notes that qualify a count.
@@ -69,6 +82,7 @@ class AnswerRendering:
69
82
  # checker verifies it at the join, so a future host that does not record gaps fails here
70
83
  # instead of silently dropping every caveat these methods raise.
71
84
  _answered_root: str | None
85
+ _scan_budget: qualifier_scan.Budget | None
72
86
  _pending_gaps: tuple[dict[str, Any], ...]
73
87
  _pending_rows: tuple[dict[str, Any], ...]
74
88
  _pending_row_cap: bool
@@ -101,7 +115,7 @@ class AnswerRendering:
101
115
  # "N other reference(s)" is always supported by the rows beneath it.
102
116
  kind = str(row.get("type(c)") or "").strip()
103
117
  kind_badge = f" [{kind}]" if kind and kind not in ("CALLS", "USAGE") else ""
104
- mark = kind_badge + _confidence_badge(row)
118
+ mark = kind_badge + _confidence_badge(row) + _qualifier_badge(row)
105
119
  return f"- module scope of {where}{mark}" if where else f"- module scope{mark}"
106
120
  name = str(row.get(name_key) or "?")
107
121
  qn = _strip_project_prefix(str(row.get(qn_key) or ""), may_be_filename=False)
@@ -113,7 +127,7 @@ class AnswerRendering:
113
127
  # A row the backend resolved by bare name rather than by import carries its score into the
114
128
  # line itself. The summary note says how many there are; only the badge says WHICH, and a
115
129
  # reader scanning for "is my symbol in here" reads rows, not notes.
116
- badge += _confidence_badge(row)
130
+ badge += _confidence_badge(row) + _qualifier_badge(row)
117
131
  return f"- {label}{badge}{tail}"
118
132
 
119
133
  @staticmethod
@@ -240,17 +254,15 @@ class AnswerRendering:
240
254
  return str(row.get(qn_key) or row.get(name_key) or ""), str(row.get(file_key) or "")
241
255
 
242
256
  def is_test(row: dict) -> bool:
243
- scope = row.get("_module_scope")
244
- return tests_last and cls._looks_like_test(
245
- str(scope) if scope is not None else str(row.get(file_key) or ""),
246
- str(row.get(name_key) or ""))
257
+ return tests_last and cls._is_test_row(row, name_key, file_key)
247
258
 
248
259
  # Production before tests inside each group, whether or not anything is cut. This is NOT the
249
- # whole printed order: `_render_edge_answer` re-sorts every group afterwards by edge kind and
250
- # then by evidence bucket, so a RESOLVED test caller prints ahead of a name-matched
251
- # production one. Because that sort is stable, production still precedes tests within each
252
- # (kind, bucket) — which is all this partition guarantees, and what a reader who stops early
253
- # within one kind of evidence gets.
260
+ # whole printed order: `_render_edge_answer` re-sorts every group afterwards by edge kind,
261
+ # then by evidence bucket, then by production-versus-test again, and only then by what the
262
+ # qualifier scan found, so a RESOLVED test caller prints ahead of a name-matched production
263
+ # one. That is the one way a test precedes production, and it is the evidence sort's, not
264
+ # this partition's: within one kind of evidence production still comes first, which is what
265
+ # a reader who stops early gets.
254
266
  if tests_last:
255
267
  for group in groups:
256
268
  group.rows.sort(key=is_test)
@@ -332,6 +344,15 @@ class AnswerRendering:
332
344
  group.rows = keep
333
345
  return dropped
334
346
 
347
+ @classmethod
348
+ def _is_test_row(cls, row: dict, name_key: str, file_key: str) -> bool:
349
+ """Whether an edge row's displayed endpoint is test code — by the file it is attributed to,
350
+ which for module-scope code is the location it was relabelled to."""
351
+ scope = row.get("_module_scope")
352
+ return cls._looks_like_test(
353
+ str(scope) if scope is not None else str(row.get(file_key) or ""),
354
+ str(row.get(name_key) or ""))
355
+
335
356
  @staticmethod
336
357
  def _looks_like_test(fp: str, name: str) -> bool:
337
358
  """Heuristic test detection. The backend's own ``is_test`` flag comes back False for pytest
@@ -836,17 +857,197 @@ class AnswerRendering:
836
857
  parts = [p for p in str(qualified).replace("/", ".").split(".") if p]
837
858
  # The segment before the leaf, when it is not the leaf itself.
838
859
  if len(parts) >= 2 and parts[-1] == wanted.name and parts[-2] != wanted.name:
839
- return parts[-2], "the name it is qualified by"
860
+ return parts[-2], _WHY_QUALIFIED
840
861
  defining = wanted.file_hint or (groups[0].file if groups else "")
841
862
  stem = str(defining).replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
842
863
  if stem and stem != wanted.name:
843
- return stem, "the module it is defined in"
864
+ return stem, _WHY_MODULE
844
865
  return None
845
866
 
867
+ @staticmethod
868
+ def _shown_groups(groups: list[_EdgeGroup]) -> list[_EdgeGroup]:
869
+ """The groups an edge answer PRINTS — the population the scan and the note about it describe.
870
+
871
+ Rows past the candidate cap are published by nobody (`_render_edge_answer` withholds them), so
872
+ a count that included them would be a count of rows no reader can see, and a verifier that
873
+ re-derives it from `rows[]` could not agree."""
874
+ return [g for g in groups if g.rows][:_CANDIDATE_CAP]
875
+
876
+ @classmethod
877
+ def _guesses_dominate(cls, groups: list[_EdgeGroup]) -> bool:
878
+ """Whether name-matched rows are enough of the answer for a check on them to be stated — the
879
+ floor `_settle_name_matches` has always applied, and the one the scan applies BEFORE it reads a
880
+ file. Below it there is no note, so a scan that ran anyway would badge rows with the note and
881
+ its caveat nowhere in the body: a mark with the qualification left off.
882
+
883
+ "Dominate" is at least `_SETTLE_FLOOR` rows and at least half the answer, or all of it. Below
884
+ half, the resolved rows carry the answer and a command here would be noise on a result that is
885
+ mostly evidence."""
886
+ matched = sum(1 for g in groups for r in g.rows if r.get("_bucket") == _NAME_MATCHED)
887
+ total = sum(len(g.rows) for g in groups)
888
+ return matched >= cls._SETTLE_FLOOR and matched * 2 >= total
889
+
890
+ @staticmethod
891
+ def _names_a_class_member(group: _EdgeGroup) -> bool:
892
+ """Whether the symbol this group's rows call is a METHOD, from the label the backend put on
893
+ the called node — the first of the two things that say the qualifier in front of it is a class.
894
+
895
+ `StrategyChain.resolve` and `api.routes.handle` are both dotted targets, and the text of the
896
+ target cannot tell them apart: the first names a class a caller must get hold of to reach the
897
+ method, the second a module a caller may reach through a re-export without ever spelling it.
898
+ The node can. Rows from a backend that does not report the label are not guessed at.
899
+
900
+ The label alone is not enough (`_owned_by` is the other half): a node can be labelled `Method`
901
+ and still have no class in front of it."""
902
+ labels = next((r.get("labels(b)") for r in group.rows if r.get("labels(b)") is not None), None)
903
+ return "Method" in _node_labels(labels)
904
+
905
+ @staticmethod
906
+ def _owned_by(group: _EdgeGroup, token: str) -> bool:
907
+ """Whether the class the backend records as defining the group's symbol is the one *token*
908
+ names — the second half of "the qualifier is a class". `owner` comes from a lookup of the
909
+ `DEFINES_METHOD` edge into the method (`DispatchCallers._establish_method_owners`), so a
910
+ method with no class node behind it, a lookup that failed and a qualifier that is a module path
911
+ all leave it blank, and a blank owner is not scanned."""
912
+ owner = group.owner.replace("/", ".").rsplit(".", 1)[-1]
913
+ return bool(owner) and owner == token
914
+
915
+ @staticmethod
916
+ def _text_can_judge(row: dict, group: _EdgeGroup, file_key: str) -> bool:
917
+ """Whether a text search of this row's file says anything about the row, so whether it is
918
+ scanned at all. Each exclusion is a case where "the file does not write the qualifier" would be
919
+ a claim about something other than the call — and where it cannot be told whether it is, the
920
+ row is left `null` and not judged, because a verdict that fails open refutes a caller nobody
921
+ looked at.
922
+
923
+ * Not name-matched. A `resolved` row followed a real binding and needs no corroboration from a
924
+ text search; scanning it would spend reads to re-derive something already known, and invite
925
+ the reading that a proven caller whose file happens not to name the class is suspect. An
926
+ `unstated` row carries no provenance for the qualifier to corroborate or undercut.
927
+ * A `via` row. It called the BASE (`CodeProvider.build_result`), and a caller of a base reaches
928
+ this override through the object's class at run time — `gateway.py` calls
929
+ `provider.build_result` and never writes `LspProvider`, which is exactly why it is a caller.
930
+ Scanning it would refute the real dispatch callers this section exists to show.
931
+ * Module-scope code. The backend attributes a module's scope to the file path it has for that
932
+ node, and has been seen naming a sibling file there (`_collapse_module_scope`), so a miss in
933
+ that file is not evidence about the code the row is about.
934
+ * The file that DEFINES the symbol. It writes the class's name because it declares it, whatever
935
+ the call does — the verdict could only ever be `true`, which would rank the row above its
936
+ peers on evidence that cannot discriminate. This is what a `same_module` edge downgraded for
937
+ being written through a receiver always is: the backend binds those inside the caller's own
938
+ file, and that file is the one that defines the callee.
939
+ * No recorded call text. The text is the only thing that says what the receiver was, and an
940
+ older index or an edge the extractor left blank has none; `_same_module_call` treats it the
941
+ same way, as unchecked.
942
+ * A language with no own-receiver rule (`_OWN_RECEIVERS`: Python, JavaScript, TypeScript). In
943
+ Java, Kotlin, C# or Ruby a call reaches an inherited member with no receiver at all, and in
944
+ every language here `self`/`this` do — in a file that never names the ancestor.
945
+ * A call on the enclosing object (`self.m()`, `this.m()`). The receiver is the caller's own
946
+ class, which reaches the method by inheritance and never by writing the ancestor's name, so
947
+ the class hierarchy is the question and a text search cannot ask it.
948
+ """
949
+ if row.get("_bucket") != _NAME_MATCHED or row.get("_via"):
950
+ return False
951
+ if row.get("_module_scope") is not None:
952
+ return False
953
+ file = str(row.get(file_key) or "")
954
+ if not file or file == group.file:
955
+ return False
956
+ callee = str(row.get("callee") or "").strip()
957
+ own = _own_receivers_of(file)
958
+ if not callee or own is None:
959
+ return False
960
+ receiver, dot, _ = callee.rpartition(".")
961
+ return not (dot and _is_own_receiver(receiver, own))
962
+
963
+ def _scan_plan(
964
+ self, op: str, shown: list[_EdgeGroup], wanted: _SymbolTarget, file_key: str,
965
+ ) -> tuple[str, list[tuple[_EdgeGroup, int]]] | None:
966
+ """What the scan WOULD judge, decided before anything is read or looked up: the token, and the
967
+ `(group, row index)` of every row `_text_can_judge` accepts in a group whose symbol is a method.
968
+ ``None`` when it would judge nothing.
969
+
970
+ Split out so the lookup of the methods' classes (`_establish_method_owners`) and the scan itself
971
+ ask the same question of the same rows — and so neither runs for an answer the note will not
972
+ speak about (`_guesses_dominate`)."""
973
+ if op != "callers" or not wanted.qualified or not self._guesses_dominate(shown):
974
+ return None
975
+ found = self._discriminator(wanted, shown)
976
+ if found is None or found[1] != _WHY_QUALIFIED:
977
+ return None
978
+ doubt = [(g, i) for g in shown if self._names_a_class_member(g)
979
+ for i, r in enumerate(g.rows) if self._text_can_judge(r, g, file_key)]
980
+ return (found[0], doubt) if doubt else None
981
+
982
+ def _scan_for_qualifier(
983
+ self, op: str, shown: list[_EdgeGroup], wanted: _SymbolTarget, file_key: str,
984
+ ) -> str | None:
985
+ """Run the check the settle note used to tell readers to run by hand, and mark the rows with
986
+ what it found. Returns the token it scanned for, or ``None`` when nothing was judged.
987
+
988
+ The note printed the `rg` command: the token, the root and the files in doubt are all in hand
989
+ at this point, so the answer can state what the grep would have found instead of describing
990
+ how to find it. Each judged row is replaced in its group by a copy carrying `_qualifier_seen`
991
+ and `_qualifier` (the backend's own rows are shared and are never written to), and everything
992
+ downstream — the sort, the printed label, `rows[]`, the evidence counts, `changed <ref>` —
993
+ reads the rows, so none of it can disagree with the note about which rows were refuted.
994
+
995
+ `shown` is the groups the answer prints (`_shown_groups`): a count over rows nobody can see
996
+ is not one a reader can check.
997
+
998
+ Scoped, and each limit was either measured or is a reason a text search cannot speak:
999
+
1000
+ * `callers` only. On `callees` the displayed rows are what the target CALLS, and a callee's
1001
+ file has no reason to write the target's own class.
1002
+ * Only when the answer is mostly name-matched rows (`_guesses_dominate`). Where the note will
1003
+ not be printed, nothing is scanned: a badge on a row without the note that qualifies it says
1004
+ more than the evidence does.
1005
+ * Only when the CALLER wrote the qualifier, and the called node is a method of a class named
1006
+ by it. The token the settle command falls back to for a bare target is the stem of the
1007
+ defining file, and a caller reaching the symbol through a re-export never writes that:
1008
+ measured on `corpus-ts`, `callerFacade.ts` imports `forwardReleasedItem` from `./facade`,
1009
+ contains no `proxy`, and a scan on the stem refuted a TRUE caller. Gating on which branch of
1010
+ `_discriminator` fired did not fix it — the discriminator also falls back to the backend's
1011
+ dotted name, so `src.proxy.forwardReleasedItem` yields `proxy` through the qualifier branch,
1012
+ a module path wearing a class qualifier's clothes. The node's own label separates the two,
1013
+ and the class the backend records as defining the method (`_owned_by`) is the check that the
1014
+ label did not over-claim.
1015
+ * Only the rows `_text_can_judge` accepts, which is where the cases a text search cannot
1016
+ speak to are left out. They stay `null`, which leaves them out of any filter built on the
1017
+ field rather than counting them as clean.
1018
+
1019
+ Nothing is removed. Absence of the qualifier is strong evidence and it is not proof — see
1020
+ `qualifier_scan` for the ways a genuine caller avoids the name.
1021
+ """
1022
+ plan = self._scan_plan(op, shown, wanted, file_key)
1023
+ if plan is None:
1024
+ return None
1025
+ # Eligible from here on, whatever the scan then finds — and whatever it fails to: a stalled
1026
+ # read, a spent budget or an owner lookup that timed out leave it judging nothing, and an
1027
+ # answer that says so is still about the filesystem as it was NOW. Recorded so the gateway
1028
+ # does not cache it (`evidence.qualifier_checked`).
1029
+ self._qualifier_attempted = True
1030
+ token, planned = plan
1031
+ doubt = [(g, i) for g, i in planned if self._owned_by(g, token)]
1032
+ if not doubt:
1033
+ return None
1034
+ root = getattr(self, "_answered_root", None) or ""
1035
+ scanned = qualifier_scan.files_naming(
1036
+ root, token, [str(g.rows[i].get(file_key) or "") for g, i in doubt],
1037
+ budget=getattr(self, "_scan_budget", None))
1038
+ if scanned is None:
1039
+ return None
1040
+ for g, i in doubt:
1041
+ seen = scanned.get(str(g.rows[i].get(file_key) or ""))
1042
+ if seen is not None:
1043
+ g.rows[i] = dict(g.rows[i], _qualifier=token, _qualifier_seen=seen)
1044
+ return token
1045
+
846
1046
  def _settle_name_matches(
847
1047
  self, groups: list[_EdgeGroup], wanted: _SymbolTarget, unit: str, file_key: str,
1048
+ scanned: str | None = None,
848
1049
  ) -> str:
849
- """The exact command that would settle the name-matched rows, or `""`.
1050
+ """What the name-matched rows come to under the one check that can discharge the doubt, or `""`.
850
1051
 
851
1052
  DISCLOSING doubt and DISCHARGING it are different services, and this project had only been
852
1053
  doing the first. An agent reading `43 name-matched` is told the answer may be wrong and
@@ -856,29 +1057,61 @@ class AnswerRendering:
856
1057
  the same time.
857
1058
 
858
1059
  On the case that motivated it — `callers StrategyChain.resolve` on a 1,483-file monorepo,
859
- 48 rows of which 43 were name-matched — the command prints the five files that mention
860
- `StrategyChain` at all, against which 43 of the 48 rows cannot be real. That is the check
861
- a person ran by hand to establish the finding in `bench/README.md`; printing it is the
862
- difference between a warning and a next step.
863
-
864
- Deliberately one command, and deliberately not a claim. It narrows; it does not decide. A
865
- file that appears in the output may still not call the symbol, and the wording says so —
866
- overstating what a grep proves would be the same defect this file is full of fixes for.
1060
+ 48 rows of which 43 were name-matched — the check finds that the five files mentioning
1061
+ `StrategyChain` at all contain none of the 43. That is the check a person ran by hand to
1062
+ establish the finding in `bench/README.md`.
1063
+
1064
+ Two shapes. When `_scan_for_qualifier` ran (`scanned` is the token it used), the note STATES
1065
+ the result — and keeps the command beneath it, because a claim a reader cannot re-run is a
1066
+ claim they have to take on trust, which is what this note exists to avoid. Otherwise nobody
1067
+ looked, and the reader is handed the command and the list of files to hold against it: the
1068
+ bare-target case, an unreadable root, a root that is not on disk.
1069
+
1070
+ `groups` is the groups the answer prints, so `N of M` counts rows a reader can see: M is the
1071
+ name-matched rows of the DIRECT list as printed — not the callers through a base, which
1072
+ `_render_via` prints apart, and not what the distinct-caller cap left out — and N the ones
1073
+ whose file never writes the token. Both are re-derivable from `rows[]`.
1074
+
1075
+ Deliberately not a verdict, in either shape. It narrows; it does not decide. A file that
1076
+ never writes the qualifier can still reach the method — `_QUALIFIER_BYPASSES` — and a file
1077
+ that does write it may not call it. Overstating what a grep proves would be the same defect
1078
+ this file is full of fixes for.
867
1079
  """
868
- matched = [r for g in groups for r in g.rows if r.get("_bucket") == _NAME_MATCHED]
869
- if len(matched) < self._SETTLE_FLOOR:
870
- return ""
871
- total = sum(len(g.rows) for g in groups)
872
- # "Dominate": at least half the answer, or all of it. Below half, the resolved rows carry
873
- # the answer and a command here would be noise on a result that is mostly evidence.
874
- if len(matched) * 2 < total:
1080
+ if not self._guesses_dominate(groups):
875
1081
  return ""
1082
+ matched = [r for g in groups for r in g.rows if r.get("_bucket") == _NAME_MATCHED]
876
1083
  found = self._discriminator(wanted, groups)
877
1084
  if found is None:
878
1085
  return ""
879
1086
  token, why = found
880
1087
 
881
1088
  root = getattr(self, "_answered_root", None) or "."
1089
+ if scanned is not None:
1090
+ absent = sum(1 for r in matched if r.get("_qualifier_seen") is False)
1091
+ unjudged = sum(1 for r in matched if r.get("_qualifier_seen") is None)
1092
+ left = (f" {unjudged} could not be judged (a file that could not be read in time or is "
1093
+ f"not plain text, the file that defines the symbol, a call on `self`/`this`, a "
1094
+ f"call in a language this check has no rule for, or module-scope code) and carry "
1095
+ f"`qualifier_seen: null`." if unjudged else "")
1096
+ judged = [str(r.get(file_key) or "") for r in matched
1097
+ if r.get("_qualifier_seen") is not None]
1098
+ rerun = f"Re-run it yourself: `{qualifier_scan.rerun_command(token, root, judged)}`_\n"
1099
+ if not absent:
1100
+ return (
1101
+ f"\n\n_Checked: **none of the {len(matched) - unjudged}** name-matched {unit}s "
1102
+ f"shown that could be judged is in a file that never writes `{token}`, {why} — "
1103
+ f"so this check narrows nothing here.{left}_\n"
1104
+ f"_It is a text search of the files as they are on disk: a file that writes the "
1105
+ f"name has not shown that it calls the symbol. {rerun}")
1106
+ return (
1107
+ f"\n\n_Checked: **{absent} of {len(matched)}** name-matched {unit}s shown are in "
1108
+ f"files that never write `{token}`, {why}, and the part of the target the name match "
1109
+ f"did not use. They are ranked last below and carry `qualifier_seen: false`.{left}_\n"
1110
+ f"_This is a text search of the files as they are on disk, and it narrows; it does "
1111
+ f"not decide. A file can reach the symbol without writing the name — through "
1112
+ f"{_QUALIFIER_BYPASSES} — so these are the {unit}s to doubt first, not {unit}s "
1113
+ f"proven false. No class-qualified Python target is in the benchmark "
1114
+ f"(`bench/README.md`). {rerun}")
882
1115
  files = sorted({str(r.get(file_key) or "") for r in matched if r.get(file_key)})
883
1116
  listing = ""
884
1117
  if files:
@@ -886,12 +1119,13 @@ class AnswerRendering:
886
1119
  more = f", … (+{len(files) - 10} more)" if len(files) > 10 else ""
887
1120
  listing = f"_The {len(files)} file(s) to check against: {shown}{more}._\n"
888
1121
  return (
889
- f"\n\n_Settle it: `rg -n --fixed-strings '{token}' {root}`_\n"
1122
+ f"\n\n_Settle it: `{qualifier_scan.rerun_command(token, root)}`_\n"
890
1123
  f"_`{token}` is {why}, and it is the part of the target the name match did not use. "
891
- f"A file that never names it cannot be reaching this symbol, so any of the "
892
- f"{len(matched)} name-matched {unit}s below whose file is absent from that output is "
893
- f"spurious. Appearing in it is not proof of a call — it narrows the list, it does not "
894
- f"decide it._\n"
1124
+ f"A file that never names it is less likely to be reaching this symbol, and that is not "
1125
+ f"proof it is not: a caller can reach it through {_QUALIFIER_BYPASSES}. So the "
1126
+ f"{len(matched)} name-matched {unit}s below whose file is absent from that output are "
1127
+ f"the ones to doubt first. Appearing in it is not proof of a call either — it narrows "
1128
+ f"the list, it does not decide it._\n"
895
1129
  + listing
896
1130
  )
897
1131
 
@@ -922,6 +1156,15 @@ class AnswerRendering:
922
1156
  There is no receiver/type evidence field. The readiness doc asks for one "when available"
923
1157
  and it is never available: the backend reports no receiver type, so a key here would be
924
1158
  `null` on every row of every answer — a field that promises a capability nobody has.
1159
+
1160
+ `qualifier_seen` is the field that IS available, and it is a different kind of claim: not
1161
+ the receiver's type, which nothing here knows, but whether this row's file contains the text
1162
+ of the qualifier the name match did not use (`qualifier`, so the field says WHICH). A fact
1163
+ about the file, checkable by the reader with one `rg`, and `null` wherever nobody looked —
1164
+ every row that followed a binding, every row that reached the symbol through a base, and
1165
+ every row a text search cannot speak to (`_text_can_judge`). It is deliberately not folded
1166
+ into `verified`: a genuine caller can reach a method without ever writing its class's name,
1167
+ so an absent qualifier lowers a row's standing without settling it.
925
1168
  """
926
1169
  bucket = str(row.get("_bucket") or _UNSTATED)
927
1170
  strategy = str(row.get("strategy") or "").strip()
@@ -937,6 +1180,8 @@ class AnswerRendering:
937
1180
  "module_scope": scope is not None,
938
1181
  "edge": edge or None,
939
1182
  "verified": bucket == _RESOLVED,
1183
+ "qualifier": row.get("_qualifier"),
1184
+ "qualifier_seen": row.get("_qualifier_seen"),
940
1185
  "evidence": bucket,
941
1186
  "strategy": strategy or None,
942
1187
  "confidence": float(confidence) if confidence is not None else None,
@@ -1006,10 +1251,19 @@ class AnswerRendering:
1006
1251
  counts = {bucket: sum(1 for r in rows if r["evidence"] == bucket)
1007
1252
  for bucket in (_RESOLVED, _NAME_MATCHED, _UNSTATED)}
1008
1253
  withheld = self._pending_withheld
1254
+ # Three states, counted separately for the same reason the buckets above are: a row whose
1255
+ # file was never opened is not a row whose file was opened and came back clean. Both counts
1256
+ # are over `possible` rows by construction (`_text_can_judge`), so `possible` minus the two
1257
+ # is how many nobody judged.
1258
+ checked = [r.get("qualifier_seen") for r in rows]
1009
1259
  return {
1010
1260
  "verified": counts[_RESOLVED],
1011
1261
  "possible": counts[_NAME_MATCHED],
1012
1262
  "unstated": counts[_UNSTATED],
1263
+ # Of the `possible` rows, how many sit in a file that never writes the qualifier — the
1264
+ # ones a reader would have eliminated with the `rg` the note used to hand them.
1265
+ "qualifier_absent": sum(1 for q in checked if q is False),
1266
+ "qualifier_present": sum(1 for q in checked if q is True),
1013
1267
  "returned": len(rows),
1014
1268
  "total": None if self._pending_row_cap else len(rows) + withheld,
1015
1269
  "truncated": bool(self._pending_row_cap or withheld),
@@ -1020,6 +1274,11 @@ class AnswerRendering:
1020
1274
  "safe_for_destructive": bool(
1021
1275
  counts[_NAME_MATCHED] == 0 and counts[_UNSTATED] == 0
1022
1276
  and not self._pending_row_cap and not withheld and not self._pending_gaps),
1277
+ # Present only when the qualifier check was eligible and attempted, so every other
1278
+ # answer's evidence is as it was. It marks an answer that depends on the files as they
1279
+ # were when it was asked — judged or, after a stall or a spent budget, not — which the
1280
+ # gateway therefore never caches.
1281
+ **({"qualifier_checked": True} if getattr(self, "_qualifier_attempted", False) else {}),
1023
1282
  }
1024
1283
 
1025
1284
  def _row_noun(self) -> str:
@@ -1088,14 +1347,30 @@ class AnswerRendering:
1088
1347
  name_key, qn_key, file_key = row_keys
1089
1348
  answered = [g for g in groups if g.rows]
1090
1349
  kept = sum(len(g.rows) for g in answered)
1350
+ # What the answer prints, which is what the scan and the note about it are over: `answered`
1351
+ # less the symbols past the candidate cap, whose rows nobody is shown.
1352
+ shown_groups = self._shown_groups(answered)
1353
+ scanned = self._scan_for_qualifier(op, shown_groups, wanted, file_key)
1091
1354
  # Rows are ordered so the direct calls come first — they are the answer to the question
1092
1355
  # that was asked — and, within those, the rows that followed a real binding come before the
1093
1356
  # ones matched by name. A reader who stops after the first few should be stopping on the
1094
1357
  # evidence, not on whichever guess the backend happened to return first.
1358
+ #
1359
+ # The scan verdict is the LAST key: it only ever reorders within a bucket, and within
1360
+ # production-versus-test inside it. A name-matched row whose file writes the qualifier sorts
1361
+ # above one nobody could judge, which sorts above one whose file does not. It cannot promote a
1362
+ # guess above a binding, because it is weaker evidence than either and ordering it as though
1363
+ # it were equal would be the same over-claim in a different place; and it cannot promote a
1364
+ # test above production, because "production code is listed first and test files last" is a
1365
+ # promise the truncation note makes and a verdict is not a reason to break it. It is a key and
1366
+ # not a filter: every row is still printed.
1095
1367
  _ORDER = {_RESOLVED: 0, _UNSTATED: 1, _NAME_MATCHED: 2}
1368
+ _SCAN_ORDER = {True: 0, None: 1, False: 2}
1096
1369
  for g in answered:
1097
1370
  g.rows.sort(key=lambda r: (str(r.get("type(c)") or "") != _DIRECT_KIND,
1098
- _ORDER.get(str(r.get("_bucket") or ""), 1)))
1371
+ _ORDER.get(str(r.get("_bucket") or ""), 1),
1372
+ op == "callers" and self._is_test_row(r, name_key, file_key),
1373
+ _SCAN_ORDER[r.get("_qualifier_seen")]))
1099
1374
  counts = self._kind_counts(answered)
1100
1375
  direct = counts.get(_DIRECT_KIND, 0)
1101
1376
  others = kept - direct
@@ -1104,9 +1379,10 @@ class AnswerRendering:
1104
1379
  else f"## {op.capitalize()} of {target} "
1105
1380
  f"({direct} direct, {others} other reference(s))\n")
1106
1381
  head += self._evidence_headline(answered, unit)
1107
- # Disclosure says the answer may be wrong; this says how to find out. Placed with
1108
- # the headline because that is the number it is about.
1109
- head += self._settle_name_matches(answered, wanted, unit, file_key)
1382
+ # Disclosure says the answer may be wrong; this says how to find out — or, when the scan
1383
+ # ran, what finding out came to. Placed with the headline because that is the number it is
1384
+ # about.
1385
+ head += self._settle_name_matches(shown_groups, wanted, unit, file_key, scanned)
1110
1386
 
1111
1387
  if len(answered) == 1:
1112
1388
  rendered = list(answered[0].rows)
@@ -1123,7 +1399,7 @@ class AnswerRendering:
1123
1399
  f"{unit}s are grouped separately rather than merged into one list. Narrow the target "
1124
1400
  f"with a qualified name or `{wanted.name}@<file>` to answer about one of them",
1125
1401
  )
1126
- shown = answered[:_CANDIDATE_CAP]
1402
+ shown = shown_groups
1127
1403
  rendered = [r for g in shown for r in g.rows]
1128
1404
  # Rows past the candidate cap are printed by nobody, so they are published by nobody
1129
1405
  # either — but they are counted, because "12 of 17 shown" and "12 shown, total