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.
- {codecortex-0.21.0/src/codecortex.egg-info → codecortex-0.22.0}/PKG-INFO +2 -2
- {codecortex-0.21.0 → codecortex-0.22.0}/README.md +1 -1
- {codecortex-0.21.0 → codecortex-0.22.0/src/codecortex.egg-info}/PKG-INFO +2 -2
- codecortex-0.22.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/graph.py +317 -57
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/wire_text.py +19 -5
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_edge_confidence.py +11 -6
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_failure_population.py +3 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_real.py +15 -3
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_null_reason_and_ripple.py +153 -1
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_wire_text.py +30 -0
- codecortex-0.21.0/src/codeintel/__init__.py +0 -1
- {codecortex-0.21.0 → codecortex-0.22.0}/LICENSE +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/pyproject.toml +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/setup.cfg +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/SOURCES.txt +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/__main__.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/agent_prompt.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/auth.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/c4.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/cache.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/__init__.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/_common.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/c4.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/doctor.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/gen_token.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/graph.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/index.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/install.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/map.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/prompt.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/query.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/reset.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/serve.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/serve_http.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/setup.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/commands/status.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/config.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/containment.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/doctor.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/gateway.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/graph_backend.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/graph_render.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/graph_resolution.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/grapher.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/http_server.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/indexer.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/injector.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/installer.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/loc.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/outcome.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/paths.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/policy.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/progress.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/py.typed +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/query_ops.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/redact.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/reset.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/searcher.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/server.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/source_kind.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/term.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/verify.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/viewer/__init__.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/src/codeintel/viewer/graph_template.html +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_agent_prompt.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_backend_reaper.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_c4.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_c4_model.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_c4_output.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cache.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_chunking.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_commands.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_help.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_install_dry_run.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cli_install_offer_injection.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_cold_process.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_config.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_corpus.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_docs_ci_claims.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_docs_deadcode_withdrawal.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_doctor.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_e2e.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_engine_adoption.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_gateway.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_provider.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_grapher.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_hardening.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_http_server.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_incompleteness.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_index_progress.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_indexer_binary.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_installer.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_integration.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_loc_census.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_lsp_language_coverage.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_mapper.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_mcp_handshake.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_mcp_server.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_model_dimension.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_rbac.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_redaction_boundary.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_reindexer.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_rerank.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_reset.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_source_kind.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_staleness.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_symbol_context.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_term.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_treesitter.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_verify.py +0 -0
- {codecortex-0.21.0 → codecortex-0.22.0}/tests/test_verify_call.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
|
1242
|
-
f"
|
|
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** —
|
|
1246
|
-
f"
|
|
1247
|
-
f"
|
|
1248
|
-
f"
|
|
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 `[?…]`
|
|
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
|
-
|
|
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 +
|
|
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 `[?…]`
|
|
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
|
-
|
|
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
|
-
|
|
1521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1695
|
-
|
|
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
|
-
#
|
|
1703
|
-
#
|
|
1704
|
-
#
|
|
1705
|
-
#
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1800
|
-
if
|
|
1801
|
-
best[key] =
|
|
1802
|
-
out = [(lbl, fp, c) for (lbl, fp), c in best.items()]
|
|
1803
|
-
|
|
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",
|