codecortex 0.21.0__tar.gz → 0.22.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 (136) hide show
  1. {codecortex-0.21.0/src/codecortex.egg-info → codecortex-0.22.0}/PKG-INFO +2 -2
  2. {codecortex-0.21.0 → codecortex-0.22.0}/README.md +1 -1
  3. {codecortex-0.21.0 → codecortex-0.22.0/src/codecortex.egg-info}/PKG-INFO +2 -2
  4. codecortex-0.22.0/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/graph.py +317 -57
  6. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/wire_text.py +19 -5
  7. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_edge_confidence.py +11 -6
  8. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_failure_population.py +3 -0
  9. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_real.py +15 -3
  10. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_null_reason_and_ripple.py +153 -1
  11. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_wire_text.py +30 -0
  12. codecortex-0.21.0/src/codeintel/__init__.py +0 -1
  13. {codecortex-0.21.0 → codecortex-0.22.0}/LICENSE +0 -0
  14. {codecortex-0.21.0 → codecortex-0.22.0}/pyproject.toml +0 -0
  15. {codecortex-0.21.0 → codecortex-0.22.0}/setup.cfg +0 -0
  16. {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/SOURCES.txt +0 -0
  17. {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  18. {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  19. {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/requires.txt +0 -0
  20. {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/top_level.txt +0 -0
  21. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/__main__.py +0 -0
  22. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/agent_prompt.py +0 -0
  23. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/auth.py +0 -0
  24. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/c4.py +0 -0
  25. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/cache.py +0 -0
  26. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/__init__.py +0 -0
  27. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/_common.py +0 -0
  28. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/c4.py +0 -0
  29. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/doctor.py +0 -0
  30. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/gen_token.py +0 -0
  31. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/graph.py +0 -0
  32. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/index.py +0 -0
  33. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/install.py +0 -0
  34. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/map.py +0 -0
  35. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/prompt.py +0 -0
  36. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/query.py +0 -0
  37. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/reset.py +0 -0
  38. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/serve.py +0 -0
  39. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/serve_http.py +0 -0
  40. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/setup.py +0 -0
  41. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/status.py +0 -0
  42. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/config.py +0 -0
  43. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/containment.py +0 -0
  44. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/doctor.py +0 -0
  45. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/gateway.py +0 -0
  46. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/graph_backend.py +0 -0
  47. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/graph_render.py +0 -0
  48. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/graph_resolution.py +0 -0
  49. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/grapher.py +0 -0
  50. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/http_server.py +0 -0
  51. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/indexer.py +0 -0
  52. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/injector.py +0 -0
  53. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/installer.py +0 -0
  54. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/loc.py +0 -0
  55. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/logconfig.py +0 -0
  56. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/mapper.py +0 -0
  57. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/metrics.py +0 -0
  58. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/onboarding.py +0 -0
  59. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/outcome.py +0 -0
  60. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/paths.py +0 -0
  61. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/policy.py +0 -0
  62. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/progress.py +0 -0
  63. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/provider.py +0 -0
  64. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/__init__.py +0 -0
  65. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/lsp.py +0 -0
  66. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/none.py +0 -0
  67. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/semantic.py +0 -0
  68. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/py.typed +0 -0
  69. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/query_ops.py +0 -0
  70. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/redact.py +0 -0
  71. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/reindexer.py +0 -0
  72. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/reset.py +0 -0
  73. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/searcher.py +0 -0
  74. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/semantic_db.py +0 -0
  75. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/server.py +0 -0
  76. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/source_kind.py +0 -0
  77. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/term.py +0 -0
  78. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/verify.py +0 -0
  79. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/viewer/__init__.py +0 -0
  80. {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/viewer/graph_template.html +0 -0
  81. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_agent_prompt.py +0 -0
  82. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_backend_reaper.py +0 -0
  83. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_c4.py +0 -0
  84. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_c4_model.py +0 -0
  85. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_c4_output.py +0 -0
  86. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cache.py +0 -0
  87. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_chunking.py +0 -0
  88. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_commands.py +0 -0
  89. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_help.py +0 -0
  90. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_install_dry_run.py +0 -0
  91. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_install_offer_injection.py +0 -0
  92. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cold_process.py +0 -0
  93. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_config.py +0 -0
  94. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_corpus.py +0 -0
  95. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_docs_ci_claims.py +0 -0
  96. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_docs_deadcode_withdrawal.py +0 -0
  97. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_doctor.py +0 -0
  98. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_e2e.py +0 -0
  99. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_engine_adoption.py +0 -0
  100. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_enterprise.py +0 -0
  101. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_gateway.py +0 -0
  102. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_provider.py +0 -0
  103. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_stdin.py +0 -0
  104. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_grapher.py +0 -0
  105. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_hardening.py +0 -0
  106. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_http_auth.py +0 -0
  107. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_http_server.py +0 -0
  108. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_incompleteness.py +0 -0
  109. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_index_progress.py +0 -0
  110. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_indexer_binary.py +0 -0
  111. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_installer.py +0 -0
  112. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_integration.py +0 -0
  113. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_loc_census.py +0 -0
  114. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_lsp_language_coverage.py +0 -0
  115. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_lsp_provider.py +0 -0
  116. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_lsp_real.py +0 -0
  117. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_mapper.py +0 -0
  118. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_mcp_handshake.py +0 -0
  119. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_mcp_server.py +0 -0
  120. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_model_dimension.py +0 -0
  121. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_never_raise.py +0 -0
  122. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_onboarding.py +0 -0
  123. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_rbac.py +0 -0
  124. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_redaction_boundary.py +0 -0
  125. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_reindexer.py +0 -0
  126. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_rerank.py +0 -0
  127. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_reset.py +0 -0
  128. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_semantic_provider.py +0 -0
  129. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_source_kind.py +0 -0
  130. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_staleness.py +0 -0
  131. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_symbol_context.py +0 -0
  132. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_term.py +0 -0
  133. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_treesitter.py +0 -0
  134. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_verify.py +0 -0
  135. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_verify_call.py +0 -0
  136. {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_version_skew.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.21.0
3
+ Version: 0.22.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
@@ -281,7 +281,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
281
281
  ```text
282
282
  v claude: registered at /Users/you/.claude.json
283
283
 
284
- v verified: codeintel 0.21.0 — 4 tools (code.query, code.status, code.doctor, code.map)
284
+ v verified: codeintel 0.22.0 — 4 tools (code.query, code.status, code.doctor, code.map)
285
285
  ```
286
286
 
287
287
  If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
@@ -244,7 +244,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
244
244
  ```text
245
245
  v claude: registered at /Users/you/.claude.json
246
246
 
247
- v verified: codeintel 0.21.0 — 4 tools (code.query, code.status, code.doctor, code.map)
247
+ v verified: codeintel 0.22.0 — 4 tools (code.query, code.status, code.doctor, code.map)
248
248
  ```
249
249
 
250
250
  If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.21.0
3
+ Version: 0.22.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
@@ -281,7 +281,7 @@ Then it launches the exact command it registered and drives a real MCP handshake
281
281
  ```text
282
282
  v claude: registered at /Users/you/.claude.json
283
283
 
284
- v verified: codeintel 0.21.0 — 4 tools (code.query, code.status, code.doctor, code.map)
284
+ v verified: codeintel 0.22.0 — 4 tools (code.query, code.status, code.doctor, code.map)
285
285
  ```
286
286
 
287
287
  If the command is not on `PATH`, or the server fails to start, install says so and exits non-zero
@@ -0,0 +1 @@
1
+ __version__ = "0.22.0"
@@ -305,6 +305,75 @@ def _language_coverage_note(rows: list[dict]) -> str:
305
305
  # answers.
306
306
  _EDGE_ROW_LIMIT = 50
307
307
 
308
+ # What each relationship kind ASSERTS, in the words a reader needs.
309
+ #
310
+ # These are different facts, not different confidences in one fact, and conflating them is a
311
+ # category error this engine used to commit twice over. `callers` matched `[:CALLS|USAGE]` and
312
+ # printed every row under one "Callers" heading — so a module-scope mention was reported as a
313
+ # caller — while `CALL_REFERENCE`, the kind that records a function being PASSED somewhere rather
314
+ # than invoked, was never queried at all. The visible cost: `forward_released_item` is registered
315
+ # via `set_forward_fn(app.forward_released_item)` at two sites, the backend had both of them
316
+ # correctly stored as CALL_REFERENCE, and codeintel answered "no callers" — the exact reading that
317
+ # gets a live method deleted. No confidence threshold could have recovered that; only asking for
318
+ # the right relationship can.
319
+ _EDGE_KINDS: dict[str, str] = {
320
+ "CALLS": "called directly",
321
+ "USAGE": "referenced, not called (module scope, or a mention that is not a call site)",
322
+ "CALL_REFERENCE": "passed as a value or registered as a callback — never invoked here",
323
+ }
324
+ _DIRECT_KIND = "CALLS"
325
+
326
+ # HOW an edge was resolved, which the backend records per edge as `c.strategy`. This is provenance,
327
+ # not a score, and it is the field that should drive policy.
328
+ #
329
+ # The confidence float alone is not enough, and shipping a threshold over it was a mistake this
330
+ # constant exists to correct: on one real repository `unique_name` — a single strategy, one kind of
331
+ # guess — appears at BOTH 0.75 and 0.38, so a numeric floor splits one strategy across two tiers and
332
+ # reports the same kind of evidence two different ways. Meanwhile `same_module` at 0.90 and
333
+ # `lsp_constructor` at 0.85 are genuinely different KINDS of claim that a float renders as neighbours.
334
+ # Read the strategy; keep the number as detail.
335
+ #
336
+ # Classified by prefix because the vocabulary is open — `lsp_direct`, `lsp_ts_method`,
337
+ # `lsp_callable_alias`, `lsp_builtin_constructor` and a dozen more are all the same class of
338
+ # evidence, and enumerating them would go stale on the backend's next release.
339
+ _TRUSTED_EVIDENCE: tuple[str, ...] = ("lsp", "import_map", "same_module")
340
+ _GUESS_EVIDENCE: tuple[str, ...] = ("unique_name", "suffix_match", "fuzzy", "qualified_suffix")
341
+
342
+
343
+ def _evidence_class(strategy: str) -> str:
344
+ """`c.strategy` reduced to the class that decides how an edge should be presented.
345
+
346
+ ``"lsp"`` / ``"import"`` / ``"same-module"`` are resolutions: something followed a real binding.
347
+ ``"name-guess"`` is the cascade falling back on a bare name matching. ``""`` means the backend
348
+ did not say, which is its own state and never silently promoted to either side."""
349
+ st = (strategy or "").strip().lower()
350
+ if not st:
351
+ return ""
352
+ # Two vocabularies reach this function and both have to be understood. `query_graph` returns the
353
+ # SPECIFIC strategy on the edge (`lsp_callable_alias`, `unique_name`, `suffix_match`);
354
+ # `trace_path --include-evidence` returns the backend's own COARSE class
355
+ # (`lsp | language_rule | heuristic | unresolved`). Mapping only the specific names left every
356
+ # traced hop labelled "other" — including the `heuristic` ones, which are exactly the guesses
357
+ # that most need saying.
358
+ if st.startswith("lsp"):
359
+ return "lsp"
360
+ if st.startswith("import"):
361
+ return "import"
362
+ if st.startswith("same_module"):
363
+ return "same-module"
364
+ if st.startswith("language_rule"):
365
+ return "language-rule"
366
+ if st.startswith("unresolved"):
367
+ return "unresolved"
368
+ # `heuristic` is the coarse class the backend uses for the weak end of its cascade — the same
369
+ # thing `unique_name`/`suffix_match` are, named one level up.
370
+ if st.startswith("heuristic"):
371
+ return "name-guess"
372
+ for guess in _GUESS_EVIDENCE:
373
+ if st.startswith(guess):
374
+ return "name-guess"
375
+ return "other"
376
+
308
377
  # How much the backend trusts an edge's target resolution — and the line below which it is a GUESS.
309
378
  #
310
379
  # The graph backend resolves each call target through a prioritised cascade and stamps the edge with
@@ -482,12 +551,17 @@ def _confidence_badge(row: dict) -> str:
482
551
  Two glyphs rather than one because the tiers mean different things and a reader scanning a list
483
552
  should be able to tell "unverified" from "probably junk" without consulting the note: `?` is a
484
553
  unique-name binding, `!` a suffix or string-similarity one."""
554
+ if "_low_confidence" not in row:
555
+ return ""
485
556
  conf = row.get("_low_confidence")
486
557
  if conf is None:
487
- return ""
488
- conf = float(conf)
489
- glyph = "!" if conf <= _EDGE_CONFIDENCE_WEAK else "?"
490
- return f" [{glyph}{conf:.2f}]"
558
+ # Condemned by its strategy, with no number attached. Say the strategy's verdict rather
559
+ # than inventing a score for it.
560
+ return " [?name-guess]"
561
+ # ONE glyph. `!` and `?` used to split on the confidence float, which put `unique_name` at 0.75
562
+ # and `unique_name` at 0.38 — the same strategy, the same kind of evidence — into two different
563
+ # visual classes. The number still shows, as detail behind a single verdict.
564
+ return f" [?{float(conf):.2f}]"
491
565
 
492
566
 
493
567
  def _group_edges(rows: list[dict], name_key: str, qn_key: str, file_key: str) -> list[_EdgeGroup]:
@@ -833,6 +907,44 @@ class GraphProvider:
833
907
  # expect `callers`/`callees` (which read through here) to honour that stub.
834
908
  _EDGE_OPS_WITH_A_SYMBOL_TARGET = ("callers", "callees", "impact", "chain", "context")
835
909
 
910
+ # Structural edges say where a symbol LIVES, not what depends on it. Naming them in a
911
+ # "nothing references this" message would answer a question nobody asked.
912
+ _STRUCTURAL_EDGES = frozenset({
913
+ "DEFINES", "DEFINES_METHOD", "CONTAINS_FILE", "CONTAINS_FOLDER", "HAS_BRANCH",
914
+ "FILE_CHANGES_WITH", "SIMILAR_TO", "SEMANTICALLY_RELATED",
915
+ })
916
+
917
+ def _dependency_kinds(self, target: str, project: str, timeout_ms: int) -> dict[str, int]:
918
+ """Every non-structural relationship touching *target*, by kind.
919
+
920
+ The point is to stop "no callers" from being the end of the sentence. A symbol reached only
921
+ by `INHERITS`, `DECORATES`, `HANDLES` or `TESTS` is not unreferenced — it is referenced in a
922
+ way this op does not cover, and saying which way is the difference between a usable answer
923
+ and one that reads as "unused"."""
924
+ wanted = _parse_symbol_target(target)
925
+ if not wanted.name:
926
+ return {}
927
+ # ALIASED. Unaliased, the backend names the column `COUNT(*)` — uppercased — while the
928
+ # query says `count(*)`, so a lookup by the written name silently missed and every kind
929
+ # rendered as "0 x KIND". An alias makes the column name ours rather than a formatting
930
+ # detail of whichever backend answered.
931
+ cypher = (
932
+ f'MATCH (a)-[c]->(b) WHERE b.name="{_cypher_literal(wanted.name)}" '
933
+ "RETURN type(c) AS kind, count(*) AS n LIMIT 30"
934
+ )
935
+ out: dict[str, int] = {}
936
+ for r in self._query_rows(cypher, project, timeout_ms):
937
+ kind = str(r.get("kind") or "").strip()
938
+ if not kind or kind in self._STRUCTURAL_EDGES:
939
+ continue
940
+ try:
941
+ count = int(str(r.get("n") or 0))
942
+ except (TypeError, ValueError):
943
+ continue
944
+ if count > 0: # a zero is a parse miss, never a fact worth printing
945
+ out[kind] = count
946
+ return out
947
+
836
948
  def _node_locations(self, target: str, project: str, timeout_ms: int) -> list[str]:
837
949
  """Where a symbol with this bare name is DEFINED, independent of whether it has edges.
838
950
 
@@ -890,7 +1002,15 @@ class GraphProvider:
890
1002
  # motivated the floor, 31 of the 32 fabricated rows were module-scope ones, so a badge
891
1003
  # applied only to the named-symbol branch below would have marked exactly one of them
892
1004
  # and left the summary count looking unsupported by the list it summarises.
893
- mark = _confidence_badge(row)
1005
+ # The suppressed badge above is about CALLS-vs-USAGE only — at module scope that
1006
+ # distinction reflects which node kind carried the edge, not a fact about the code, so
1007
+ # printing it would assert something the backend did not mean. `CALL_REFERENCE` is a
1008
+ # different claim: it says the symbol was PASSED rather than invoked, which is true of
1009
+ # the code regardless of which node holds the edge. It is shown, so a header reading
1010
+ # "N other reference(s)" is always supported by the rows beneath it.
1011
+ kind = str(row.get("type(c)") or "").strip()
1012
+ kind_badge = f" [{kind}]" if kind and kind not in ("CALLS", "USAGE") else ""
1013
+ mark = kind_badge + _confidence_badge(row)
894
1014
  return f"- module scope of {where}{mark}" if where else f"- module scope{mark}"
895
1015
  name = str(row.get(name_key) or "?")
896
1016
  qn = _strip_project_prefix(str(row.get(qn_key) or ""), may_be_filename=False)
@@ -1091,8 +1211,9 @@ class GraphProvider:
1091
1211
  the same name is worse than an un-narrowed one: it reads as precise."""
1092
1212
  wanted = _parse_symbol_target(target)
1093
1213
  cypher = (
1094
- f'MATCH (a)-[c:CALLS|USAGE]->(b) WHERE b.name="{_cypher_literal(wanted.name)}" '
1214
+ f'MATCH (a)-[c:CALLS|USAGE|CALL_REFERENCE]->(b) WHERE b.name="{_cypher_literal(wanted.name)}" '
1095
1215
  "RETURN a.name, a.qualified_name, a.file_path, labels(a), type(c), c.confidence, "
1216
+ "c.strategy AS strategy, "
1096
1217
  f"b.name, b.qualified_name, b.file_path LIMIT {_EDGE_ROW_LIMIT}"
1097
1218
  )
1098
1219
  rows = self._query_rows(cypher, project, timeout_ms)
@@ -1166,6 +1287,43 @@ class GraphProvider:
1166
1287
  return (f"\n\n_Truncated: the graph returned the maximum {_EDGE_ROW_LIMIT} rows, so rows "
1167
1288
  f"beyond that are missing from this list._")
1168
1289
 
1290
+ @staticmethod
1291
+ def _kind_counts(groups: list[_EdgeGroup]) -> dict[str, int]:
1292
+ """How many rows of each relationship kind this answer holds."""
1293
+ out: dict[str, int] = {}
1294
+ for g in groups:
1295
+ for r in g.rows:
1296
+ kind = str(r.get("type(c)") or "").strip() or "?"
1297
+ # A module-scope row's CALLS/USAGE label is an artifact of which node kind carried
1298
+ # the edge (see `_display`), so it is counted as a direct row rather than split out
1299
+ # as a distinct KIND of fact. Anything else — notably CALL_REFERENCE — is a real
1300
+ # claim about the code and is counted as itself.
1301
+ if r.get("_module_scope") is not None and kind in ("CALLS", "USAGE"):
1302
+ kind = _DIRECT_KIND
1303
+ out[kind] = out.get(kind, 0) + 1
1304
+ return out
1305
+
1306
+ def _kind_note(self, op: str, counts: dict[str, int]) -> str:
1307
+ """Name every non-call relationship in the answer, and what it actually asserts.
1308
+
1309
+ The count in the heading is the direct calls. Everything else is real — the edges exist and
1310
+ an agent asking "what depends on this" needs them — but it is not a call, and printing it
1311
+ under a caller count is the category error that made a registered callback look unused."""
1312
+ others = {k: n for k, n in counts.items() if k != _DIRECT_KIND}
1313
+ if not others:
1314
+ return ""
1315
+ total = sum(others.values())
1316
+ parts = [f"{n} `{k}` row(s) ({_EDGE_KINDS.get(k, 'relationship kind not described here')})"
1317
+ for k, n in sorted(others.items(), key=lambda kv: -kv[1])]
1318
+ self._add_gap(
1319
+ op, "non-call-relationships",
1320
+ f"{total} of the rows are not direct calls: " + "; ".join(
1321
+ f"{n} {k}" for k, n in sorted(others.items(), key=lambda kv: -kv[1])),
1322
+ )
1323
+ return ("\n\n_Not calls — " + "; ".join(parts) + ". Each row is badged with its kind. "
1324
+ "For \"what would break if this changed\" you want all of them; for \"what calls "
1325
+ "this\" you want only the `CALLS` rows._")
1326
+
1169
1327
  def _confidence_note(self, op: str, groups: list[_EdgeGroup]) -> str:
1170
1328
  """Mark every row the backend resolved by NAME rather than by import, and disclose the count.
1171
1329
 
@@ -1195,9 +1353,24 @@ class GraphProvider:
1195
1353
  backend is not a low score, and flattening the two is the same error one level down.
1196
1354
  """
1197
1355
  weak = unverified = unstamped = no_column = total = 0
1356
+ guessed_by: set[str] = set()
1198
1357
  for g in groups:
1199
1358
  for r in g.rows:
1200
1359
  total += 1
1360
+ # PROVENANCE FIRST. `c.strategy` says how the edge was resolved, which is the fact;
1361
+ # the float is a summary of it. Where the backend reports a strategy it decides,
1362
+ # and the numeric floor below is only the fallback for a backend generation that
1363
+ # does not (0.9.x, and any edge the newer one leaves unlabelled).
1364
+ evidence = _evidence_class(str(r.get("strategy") or ""))
1365
+ if evidence == "name-guess":
1366
+ r["_low_confidence"] = _edge_confidence(r)
1367
+ r["_evidence"] = evidence
1368
+ guessed_by.add(str(r.get("strategy") or "").strip())
1369
+ unverified += 1
1370
+ continue
1371
+ if evidence in ("lsp", "import", "same-module"):
1372
+ r["_evidence"] = evidence
1373
+ continue
1201
1374
  conf = _edge_confidence(r)
1202
1375
  if conf is None:
1203
1376
  # Two different silences, and only one of them is about the code. A row with NO
@@ -1237,15 +1410,20 @@ class GraphProvider:
1237
1410
  f"string-similarity match (confidence <= {_EDGE_CONFIDENCE_WEAK}), not by any "
1238
1411
  f"import.")
1239
1412
  if unverified:
1413
+ # Name the STRATEGY when the backend reported one. "resolved by `unique_name`" is a
1414
+ # fact about how the edge was produced; "confidence < 0.85" is a fact about a threshold
1415
+ # this code chose, and only the first tells a reader what to distrust.
1416
+ named = ", ".join(f"`{st}`" for st in sorted(s for s in guessed_by if s))
1417
+ how = (f"by name matching ({named})" if named
1418
+ else f"by bare symbol name (confidence < {_EDGE_CONFIDENCE_FLOOR})")
1240
1419
  details.append(
1241
- f"{unverified} of {total} row(s) were resolved by bare symbol name because this is "
1242
- f"the only symbol of that name in the index (confidence < {_EDGE_CONFIDENCE_FLOOR}), "
1243
- f"not by following the calling file's imports")
1420
+ f"{unverified} of {total} row(s) were resolved {how}, not by following an import or "
1421
+ f"a language-server binding")
1244
1422
  parts.append(
1245
- f"**{unverified} of {total} row(s) are UNVERIFIED** — bound because this is the only "
1246
- f"symbol of that name in the index (confidence < {_EDGE_CONFIDENCE_FLOOR}), not by "
1247
- f"following the calling file's imports. Correct when the call really targets this "
1248
- f"symbol; wrong when it targets a same-named symbol the index never saw.")
1423
+ f"**{unverified} of {total} row(s) are UNVERIFIED** — resolved {how}, not by "
1424
+ f"following an import or a language-server binding. Correct when the call really "
1425
+ f"targets this symbol; wrong when it targets a same-named symbol the index never "
1426
+ f"saw.")
1249
1427
  if unstamped:
1250
1428
  details.append(
1251
1429
  f"{unstamped} of {total} row(s) carry no confidence from the backend at all, so how "
@@ -1273,8 +1451,7 @@ class GraphProvider:
1273
1451
  "method — collecting every call site in the repository that mentions it. Treat the "
1274
1452
  "whole answer as unconfirmed until `--engine lsp` agrees.")
1275
1453
  self._add_gap(op, "low-confidence-edges", "; ".join(details))
1276
- marked = "Marked `[?…]` (unverified) and `[!…]` (likely spurious) below. " if (
1277
- weak or unverified) else ""
1454
+ marked = "Marked `[?…]` below. " if (weak or unverified) else ""
1278
1455
  return "\n\n_" + marked + " ".join(parts) + "_"
1279
1456
 
1280
1457
  def _collision_note(self, op: str, dropped: int) -> str:
@@ -1349,8 +1526,9 @@ class GraphProvider:
1349
1526
  """
1350
1527
  wanted = _parse_symbol_target(target)
1351
1528
  cypher = (
1352
- f'MATCH (a)-[c:CALLS|USAGE]->(b) WHERE a.name="{_cypher_literal(wanted.name)}" '
1529
+ f'MATCH (a)-[c:CALLS|USAGE|CALL_REFERENCE]->(b) WHERE a.name="{_cypher_literal(wanted.name)}" '
1353
1530
  "RETURN b.name, b.qualified_name, b.file_path, labels(b), type(c), c.confidence, "
1531
+ "c.strategy AS strategy, "
1354
1532
  f"a.name, a.qualified_name, a.file_path LIMIT {_EDGE_ROW_LIMIT}"
1355
1533
  )
1356
1534
  rows = self._query_rows(cypher, project, timeout_ms)
@@ -1395,7 +1573,17 @@ class GraphProvider:
1395
1573
  name_key, qn_key, file_key = row_keys
1396
1574
  answered = [g for g in groups if g.rows]
1397
1575
  kept = sum(len(g.rows) for g in answered)
1398
- head = f"## {op.capitalize()} of {target} ({kept})\n"
1576
+ # Rows are ordered so the direct calls come first: they are the answer to the question that
1577
+ # was asked, and the rest is context beneath it.
1578
+ for g in answered:
1579
+ g.rows.sort(key=lambda r: str(r.get("type(c)") or "") != _DIRECT_KIND)
1580
+ counts = self._kind_counts(answered)
1581
+ direct = counts.get(_DIRECT_KIND, 0)
1582
+ others = kept - direct
1583
+ # A single count is honest only when every row is the same kind of fact.
1584
+ head = (f"## {op.capitalize()} of {target} ({kept})\n" if not others
1585
+ else f"## {op.capitalize()} of {target} "
1586
+ f"({direct} direct, {others} other reference(s))\n")
1399
1587
 
1400
1588
  if len(answered) == 1:
1401
1589
  body = head + "\n".join(self._display(r, name_key, qn_key, file_key)
@@ -1424,7 +1612,8 @@ class GraphProvider:
1424
1612
  f"`{answered[0].label or wanted.name}` or "
1425
1613
  f"`{wanted.name}@{answered[0].file or '<file>'}` for a single symbol.\n"
1426
1614
  + "\n" + "\n\n".join(sections))
1427
- return body + extra_notes + self._name_resolution_note(wanted, answered, truncated)
1615
+ return (body + self._kind_note(op, counts) + extra_notes
1616
+ + self._name_resolution_note(wanted, answered, truncated))
1428
1617
 
1429
1618
  def _name_resolution_note(
1430
1619
  self, wanted: _SymbolTarget, groups: list[_EdgeGroup], truncated: bool
@@ -1456,7 +1645,7 @@ class GraphProvider:
1456
1645
  because = (
1457
1646
  f" — which is also why any unresolved call to a `{wanted.name}` the index does not "
1458
1647
  f"contain (a library function, a framework global, a builtin method) binds here. "
1459
- f"Rows badged `[?…]` or `[!…]` are those bindings."
1648
+ f"Rows badged `[?…]` are those bindings."
1460
1649
  ) if badged else "."
1461
1650
  return (f"\n\n_Resolved by symbol NAME, not by type: {groups[0].describe()} is the "
1462
1651
  f"only symbol named `{wanted.name}` with edges in this index{because}_")
@@ -1491,7 +1680,19 @@ class GraphProvider:
1491
1680
  return None
1492
1681
  raw = self._run(
1493
1682
  "trace_path",
1494
- {"project": project, "function_name": src, "mode": "calls", "risk_labels": True},
1683
+ # `edge_types` widens the walk to the relationship that records a callback being
1684
+ # registered, so a chain no longer stops dead at the point a function is handed to
1685
+ # something else rather than invoked.
1686
+ #
1687
+ # `include_evidence` replaces `risk_labels`, which the backend treats as mutually
1688
+ # exclusive with it. Nothing is lost: `risk` is a restatement of hop distance
1689
+ # (hop 1 = CRITICAL, hop 2 = HIGH, hop 3 = MEDIUM) and the hop is already printed on
1690
+ # every row, so it dressed a number this op shows anyway as an assessment it never
1691
+ # made. Evidence is the fact it could not previously report — how each hop was
1692
+ # resolved.
1693
+ {"project": project, "function_name": src, "mode": "calls",
1694
+ "direction": "both", "include_evidence": True,
1695
+ "edge_types": ["CALLS", "CALL_REFERENCE"]},
1495
1696
  timeout_ms,
1496
1697
  )
1497
1698
  if not isinstance(raw, dict):
@@ -1514,11 +1715,20 @@ class GraphProvider:
1514
1715
  qn = _strip_project_prefix(str(it.get("qualified_name") or ""),
1515
1716
  may_be_filename=False)
1516
1717
  hop = it.get("hop")
1517
- risk = it.get("risk")
1518
1718
  label = qn or nm
1519
1719
  hop_s = f" [hop {hop}]" if hop is not None else ""
1520
- risk_s = f" [risk: {risk}]" if risk else ""
1521
- out.append(f"- {label}{hop_s}{risk_s}")
1720
+ # How this hop was resolved, in the same vocabulary `callers` uses, so a reader
1721
+ # does not have to learn two. A guessed hop is the one that makes a whole chain
1722
+ # downstream of it suspect, and it used to be indistinguishable.
1723
+ klass = _evidence_class(str(it.get("strategy") or ""))
1724
+ ev_s = ""
1725
+ if klass == "name-guess":
1726
+ ev_s = " [?name-guess]"
1727
+ elif klass:
1728
+ ev_s = f" [{klass}]"
1729
+ risk = it.get("risk")
1730
+ risk_s = f" [risk: {risk}]" if risk and not klass else ""
1731
+ out.append(f"- {label}{hop_s}{ev_s}{risk_s}")
1522
1732
  return out
1523
1733
 
1524
1734
  callees = _fmt(raw.get("callees"))
@@ -1684,54 +1894,80 @@ class GraphProvider:
1684
1894
  if key in seen_s:
1685
1895
  continue
1686
1896
  seen_s.add(key)
1687
- syms.append((label, fp))
1897
+ try:
1898
+ hop = int(str(s.get("hop") or 0))
1899
+ except (TypeError, ValueError):
1900
+ hop = 0
1901
+ syms.append((label, fp, hop))
1688
1902
  if not files and not syms:
1689
1903
  if dropped:
1690
1904
  return ("## Changes impact\n(no source changes — the working tree's "
1691
1905
  f"{dropped} uncommitted change(s) are all non-source files)")
1692
1906
  return "## Changes impact\n(working tree clean — no uncommitted changes)"
1693
1907
  ripple, ripple_truncated = self._changed_ripple(files, project, timeout_ms)
1694
- parts = [f"## Changes impact ({len(files)} files {len(syms)} symbols defined in them "
1695
- f" {len(ripple)} callers elsewhere)"]
1908
+ walked_hdr = any(h > 0 for _, _, h in syms)
1909
+ parts = [f"## Changes impact ({len(files)} files → {len(syms)} "
1910
+ + ("symbols impacted" if walked_hdr else "symbols defined in them")
1911
+ + f" → {len(ripple)} callers elsewhere)"]
1696
1912
  if files:
1697
1913
  parts.append(f"### Changed files ({len(files)})")
1698
1914
  parts.extend(f"- {f}" for f in files[:40])
1699
1915
  if len(files) > 40:
1700
1916
  parts.append(f"… (+{len(files) - 40} more)")
1701
1917
  if syms:
1702
- # Named for what they are. These are the symbols the edit CONTAINS — everything
1703
- # defined in a touched file, whether or not the edit came near it — which is a
1704
- # different and much weaker claim than "impacted", the word that used to head this
1705
- # list and the reason it read as a blast radius.
1706
- parts.append(f"### Symbols defined in the changed files ({len(syms)})")
1707
- for label, fp in syms[:40]:
1918
+ # The same backend field means two different things across dialects, so the heading
1919
+ # is derived from the data rather than assumed. 0.9.x returns the symbols the edit
1920
+ # CONTAINS everything defined in a touched file, whether or not the edit came near
1921
+ # it, which is a much weaker claim than "impacted". 0.10.x already returns a
1922
+ # transitive walk and stamps each row with a `hop`. Labelling a walk as containment
1923
+ # put symbols from three other files under "defined in the changed files".
1924
+ walked = any(h > 0 for _, _, h in syms)
1925
+ parts.append(
1926
+ f"### Symbols the backend reports as impacted, up to {max(h for _, _, h in syms)}"
1927
+ f" hop(s) ({len(syms)})" if walked
1928
+ else f"### Symbols defined in the changed files ({len(syms)})")
1929
+ for label, fp, hop in syms[:40]:
1708
1930
  tail = f" ({fp})" if fp and fp != label else ""
1709
- parts.append(f"- {label}{tail}")
1931
+ hop_s = f" [hop {hop}]" if hop > 0 else ""
1932
+ parts.append(f"- {label}{hop_s}{tail}")
1710
1933
  if len(syms) > 40:
1711
1934
  parts.append(f"… (+{len(syms) - 40} more)")
1712
1935
  if ripple:
1713
1936
  parts.append(f"### Callers elsewhere that reach into them ({len(ripple)})")
1714
1937
  parts.append("_This is the blast radius: symbols outside the changed files whose "
1715
1938
  "behaviour can move because of this edit._")
1716
- for label, fp, conf in ripple[:40]:
1939
+ for label, fp, conf, kind, evidence in ripple[:40]:
1717
1940
  tail = f" ({fp})" if fp and fp != label else ""
1718
- if conf is None:
1719
- mark = " [unscored]"
1720
- elif conf <= _EDGE_CONFIDENCE_WEAK:
1721
- mark = f" [!{conf:.2f}]"
1722
- elif conf < _EDGE_CONFIDENCE_FLOOR:
1941
+ # The relationship comes first: "registered" and "calls" are different things
1942
+ # to check, and a reviewer triages on that before they weigh confidence.
1943
+ kind_s = f" [{kind}]" if kind != _DIRECT_KIND else ""
1944
+ if evidence == "name-guess":
1945
+ mark = f" [?{conf:.2f}]" if conf is not None else " [?name-guess]"
1946
+ elif not evidence and conf is not None and conf < _EDGE_CONFIDENCE_FLOOR:
1947
+ # No strategy reported (0.9.x, or an edge the newer backend left
1948
+ # unlabelled), so the float is the only signal there is. Dropping this
1949
+ # branch silently un-flagged every soft edge on the older backend.
1723
1950
  mark = f" [?{conf:.2f}]"
1951
+ elif conf is None and not evidence:
1952
+ mark = " [unscored]"
1724
1953
  else:
1725
1954
  mark = ""
1726
- parts.append(f"- {label or fp}{mark}{tail}")
1955
+ parts.append(f"- {label or fp}{kind_s}{mark}{tail}")
1727
1956
  if len(ripple) > 40:
1728
1957
  parts.append(f"… (+{len(ripple) - 40} more)")
1729
- weak = sum(1 for _, _, c in ripple
1730
- if c is not None and c < _EDGE_CONFIDENCE_FLOOR)
1731
- if weak:
1732
- parts.append(f"\n_{weak} of {len(ripple)} caller(s) were resolved by symbol "
1733
- f"name rather than by import (`?`/`!` above) and may not reach "
1734
- f"this code at all._")
1958
+ guessed = sum(1 for r in ripple
1959
+ if r[4] == "name-guess"
1960
+ or (not r[4] and r[2] is not None and r[2] < _EDGE_CONFIDENCE_FLOOR))
1961
+ indirect = sum(1 for r in ripple if r[3] != _DIRECT_KIND)
1962
+ notes = []
1963
+ if indirect:
1964
+ notes.append(f"{indirect} of {len(ripple)} reach this code without calling it "
1965
+ f"(registered as a callback, or referenced) — they can still break")
1966
+ if guessed:
1967
+ notes.append(f"{guessed} were resolved by name matching and may not reach this "
1968
+ f"code at all")
1969
+ if notes:
1970
+ parts.append("\n_" + "; ".join(notes) + "._")
1735
1971
  elif files:
1736
1972
  # An empty ripple is a real and useful answer — but only if it cannot be confused
1737
1973
  # with one that was never computed.
@@ -1755,7 +1991,7 @@ class GraphProvider:
1755
1991
 
1756
1992
  def _changed_ripple(
1757
1993
  self, files: list[str], project: str, timeout_ms: int
1758
- ) -> tuple[list[tuple[str, str, float | None]], bool]:
1994
+ ) -> tuple[list[tuple[str, str, float | None, str, str]], bool]:
1759
1995
  """Symbols OUTSIDE the changed files that call into them — the actual blast radius.
1760
1996
 
1761
1997
  `changed` used to stop at containment and call it impact. Editing one function in
@@ -1777,30 +2013,45 @@ class GraphProvider:
1777
2013
  return [], False
1778
2014
  listed = files[: self._RIPPLE_FILE_CAP]
1779
2015
  in_list = ", ".join(f'"{_cypher_literal(f)}"' for f in listed)
2016
+ # CALLS is not the whole blast radius. A function REGISTERED somewhere
2017
+ # (`set_forward_fn(app.forward_released_item)`) breaks just as thoroughly when its signature
2018
+ # moves, and the edge recording that is CALL_REFERENCE. USAGE is in for the same reason:
2019
+ # this op answers a recall question — "what should I look at before committing" — and the
2020
+ # asymmetry matters. Under-reporting impact is how live code gets broken; over-reporting it
2021
+ # costs a reader one line, and every row says which kind it is.
1780
2022
  cypher = (
1781
- f"MATCH (a)-[c:CALLS]->(b) WHERE b.file_path IN [{in_list}] "
2023
+ f"MATCH (a)-[c:CALLS|CALL_REFERENCE|USAGE]->(b) WHERE b.file_path IN [{in_list}] "
1782
2024
  f"AND NOT a.file_path IN [{in_list}] "
1783
- "RETURN a.qualified_name, a.file_path, c.confidence "
2025
+ "RETURN a.qualified_name, a.file_path, c.confidence, type(c) AS kind, "
2026
+ "c.strategy AS strategy "
1784
2027
  f"LIMIT {self._RIPPLE_ROW_CAP}"
1785
2028
  )
1786
2029
  rows = self._query_rows(cypher, project, timeout_ms)
1787
2030
  truncated = len(rows) >= self._RIPPLE_ROW_CAP or len(files) > self._RIPPLE_FILE_CAP
1788
2031
  # One entry per calling symbol, keeping its best-scored edge: a caller that reaches three
1789
2032
  # changed symbols is one thing to review, not three.
1790
- best: dict[tuple[str, str], float | None] = {}
2033
+ # One row per calling symbol: a caller that reaches three changed symbols is one thing to
2034
+ # review, not three. Where the same caller has several kinds of edge, the STRONGEST claim
2035
+ # wins — a direct call outranks a registration, which outranks a bare reference — because
2036
+ # that is the one a reviewer needs to see first.
2037
+ rank = {_DIRECT_KIND: 0, "CALL_REFERENCE": 1, "USAGE": 2}
2038
+ best: dict[tuple[str, str], tuple[int, float | None, str, str]] = {}
1791
2039
  for r in rows:
1792
2040
  label = _strip_project_prefix(
1793
2041
  str(r.get("a.qualified_name") or ""), may_be_filename=False)
1794
2042
  fp = str(r.get("a.file_path") or "")
1795
2043
  if not label and not fp:
1796
2044
  continue
1797
- conf = _edge_confidence(r)
2045
+ kind = str(r.get("kind") or "").strip() or _DIRECT_KIND
2046
+ evidence = _evidence_class(str(r.get("strategy") or ""))
2047
+ cand = (rank.get(kind, 3), _edge_confidence(r), kind, evidence)
1798
2048
  key = (label, fp)
1799
- prev = best.get(key, -1.0)
1800
- if key not in best or (conf is not None and (prev is None or conf > prev)):
1801
- best[key] = conf
1802
- out = [(lbl, fp, c) for (lbl, fp), c in best.items()]
1803
- out.sort(key=lambda t: (-(t[2] if t[2] is not None else -1.0), t[0]))
2049
+ prev = best.get(key)
2050
+ if prev is None or (cand[0], -(cand[1] or -1.0)) < (prev[0], -(prev[1] or -1.0)):
2051
+ best[key] = cand
2052
+ out = [(lbl, fp, c[1], c[2], c[3]) for (lbl, fp), c in best.items()]
2053
+ # Strongest kind first, then best-resolved within it.
2054
+ out.sort(key=lambda t: (rank.get(t[3], 3), -(t[2] if t[2] is not None else -1.0), t[0]))
1804
2055
  return out, truncated
1805
2056
 
1806
2057
  def _op_hotspots(self, project: str, timeout_ms: int) -> str | None:
@@ -1994,6 +2245,14 @@ class GraphProvider:
1994
2245
  if op_str in self._EDGE_OPS_WITH_A_SYMBOL_TARGET:
1995
2246
  where = self._node_locations(target_str, project, timeout_ms)
1996
2247
  if where:
2248
+ # …and say what DOES point at it. "No callers" plus silence reads as
2249
+ # "unused"; "no callers, but 3 DECORATES and 2 TESTS" is an answer.
2250
+ kinds = self._dependency_kinds(target_str, project, timeout_ms)
2251
+ other = (" Other relationships DO point at it: "
2252
+ + ", ".join(f"{n} {k}" for k, n in
2253
+ sorted(kinds.items(), key=lambda kv: -kv[1])[:5])
2254
+ + " — query those before concluding anything about it."
2255
+ ) if kinds else ""
1997
2256
  return safe_null_result(
1998
2257
  op_str, target_str, engine="graph", reason="no-edges",
1999
2258
  hint=f"`{target_str}` IS indexed ({'; '.join(where[:3])}) — it has no "
@@ -2001,7 +2260,8 @@ class GraphProvider:
2001
2260
  f"absent. Framework-dispatched handlers (routes, ASGI apps) and "
2002
2261
  f"symbols passed as a value rather than called look exactly like "
2003
2262
  f"this, so do NOT read it as dead code. Re-indexing will not "
2004
- f"change it; confirm with `--engine lsp` or `--op pattern`.",
2263
+ f"change it; confirm with `--engine lsp` or `--op pattern`."
2264
+ + other,
2005
2265
  )
2006
2266
  return safe_null_result(
2007
2267
  op_str, target_str, engine="graph", reason="not-in-graph",