descry-codegraph 0.3.0__tar.gz → 0.3.1__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 (134) hide show
  1. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/CHANGELOG.md +33 -0
  2. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/CLAUDE.md +1 -1
  3. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/PKG-INFO +2 -2
  4. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/README.md +1 -1
  5. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/pyproject.toml +1 -1
  6. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/__init__.py +1 -1
  7. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/_graph.py +5 -0
  8. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/cli.py +1 -1
  9. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/generate.py +14 -18
  10. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/git_history.py +3 -4
  11. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/handlers.py +120 -145
  12. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/mcp_server.py +3 -3
  13. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/query.py +25 -31
  14. descry_codegraph-0.3.1/src/descry/scip/adapters/_empty_environment.json +1 -0
  15. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/python.py +12 -1
  16. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/cache.py +7 -3
  17. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/web/web/index.html +16 -12
  18. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_generate.py +19 -0
  19. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_git_history.py +45 -0
  20. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_handlers.py +44 -3
  21. descry_codegraph-0.3.1/tests/test_interfaces.py +178 -0
  22. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_query.py +66 -1
  23. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_cache.py +38 -0
  24. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_web_server.py +42 -19
  25. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/.github/assets/01-landing.png +0 -0
  26. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/.github/workflows/ci.yml +0 -0
  27. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/.github/workflows/release.yml +0 -0
  28. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/.gitignore +0 -0
  29. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/LICENSE +0 -0
  30. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/SECURITY.md +0 -0
  31. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/THIRD-PARTY-NOTICES.md +0 -0
  32. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/claude-skill/README.md +0 -0
  33. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/claude-skill/descry/SKILL.md +0 -0
  34. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/claude-skill/descry/references/cli-reference.md +0 -0
  35. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/claude-skill/descry/references/mcp-tools.md +0 -0
  36. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/claude-skill/descry/references/recipes.md +0 -0
  37. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/justfile +0 -0
  38. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/pi-extension/README.md +0 -0
  39. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/pi-extension/descry-cli.py +0 -0
  40. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/pi-extension/descry-tools.ts +0 -0
  41. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/_comments.py +0 -0
  42. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/_env.py +0 -0
  43. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/_files.py +0 -0
  44. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/_stdlib.py +0 -0
  45. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/clang_parser.py +0 -0
  46. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/cross_lang.py +0 -0
  47. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/dart_parser.py +0 -0
  48. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/dotnet_parser.py +0 -0
  49. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/embeddings.py +0 -0
  50. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/go_parser.py +0 -0
  51. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/java_parser.py +0 -0
  52. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/php_parser.py +0 -0
  53. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/ruby_parser.py +0 -0
  54. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/__init__.py +0 -0
  55. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapter.py +0 -0
  56. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/__init__.py +0 -0
  57. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/_java_compat.init.gradle +0 -0
  58. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/clang.py +0 -0
  59. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/dart.py +0 -0
  60. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/dotnet.py +0 -0
  61. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/go.py +0 -0
  62. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/java.py +0 -0
  63. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/php.py +0 -0
  64. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/ruby.py +0 -0
  65. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/rust.py +0 -0
  66. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/adapters/typescript.py +0 -0
  67. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/parser.py +0 -0
  68. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/pb2.py +0 -0
  69. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/scip/support.py +0 -0
  70. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/c.json +0 -0
  71. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/cpp.json +0 -0
  72. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/dart.json +0 -0
  73. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/dotnet.json +0 -0
  74. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/go.json +0 -0
  75. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/java.json +0 -0
  76. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/node.json +0 -0
  77. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/php.json +0 -0
  78. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/python.json +0 -0
  79. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/ruby.json +0 -0
  80. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/rust.json +0 -0
  81. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/stdlib/typescript.json +0 -0
  82. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/web/__init__.py +0 -0
  83. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/web/server.py +0 -0
  84. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/src/descry/web/web/style.css +0 -0
  85. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/__init__.py +0 -0
  86. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/conftest.py +0 -0
  87. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/README.md +0 -0
  88. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/__init__.py +0 -0
  89. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/compare.py +0 -0
  90. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/corpus.py +0 -0
  91. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/metrics.py +0 -0
  92. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/registry.py +0 -0
  93. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/results/results-descry.json +0 -0
  94. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/eval/results/results-ripgrep.json +0 -0
  95. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/fixtures/small_graph.json +0 -0
  96. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_backtracking.py +0 -0
  97. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_clang_parser.py +0 -0
  98. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_cli.py +0 -0
  99. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_config.py +0 -0
  100. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_cross_lang.py +0 -0
  101. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_dart_parser.py +0 -0
  102. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_dotnet_parser.py +0 -0
  103. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_embeddings.py +0 -0
  104. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_go_parser.py +0 -0
  105. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_hardening.py +0 -0
  106. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_java_parser.py +0 -0
  107. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_mcp.py +0 -0
  108. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_non_regular_files.py +0 -0
  109. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_php_parser.py +0 -0
  110. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_ruby_parser.py +0 -0
  111. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip.py +0 -0
  112. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_clang.py +0 -0
  113. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_dart.py +0 -0
  114. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_dotnet.py +0 -0
  115. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_go.py +0 -0
  116. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_java.py +0 -0
  117. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_php.py +0 -0
  118. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_scip_ruby.py +0 -0
  119. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_stdlib.py +0 -0
  120. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tests/test_written_through.py +0 -0
  121. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/README.md +0 -0
  122. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/common.py +0 -0
  123. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_c.py +0 -0
  124. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_cpp.py +0 -0
  125. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_dart.py +0 -0
  126. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_dotnet.py +0 -0
  127. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_go.py +0 -0
  128. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_java.py +0 -0
  129. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_node.py +0 -0
  130. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_php.py +0 -0
  131. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_python.py +0 -0
  132. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_ruby.py +0 -0
  133. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_rust.py +0 -0
  134. {descry_codegraph-0.3.0 → descry_codegraph-0.3.1}/tools/stdlib/gen_typescript.py +0 -0
@@ -6,6 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] — 2026-10-22
10
+
11
+ ### Fixed
12
+
13
+ - The callers a change lists (`changes`), and those `context` lists and
14
+ counts and an upstream `flow` follows, are the callers of that symbol,
15
+ not of every symbol of its name. `context` marks a caller that reaches
16
+ the symbol only as one of an unresolved call's candidates, as `callers`
17
+ does.
18
+ - A symbol's caller count (`in_degree`, shown as "N callers") counts the
19
+ symbols that call or construct it, not every edge into it: the file
20
+ defining it was counted as a caller.
21
+ - A Python signature is written as the source writes it, with defaults,
22
+ `*`, `**`, keyword-only parameters and `async`, and with no `-> None`
23
+ where no return type is annotated. An unannotated function was recorded
24
+ as returning `None`.
25
+ - The web UI's Language filter lists the graph's languages, which the
26
+ graph status now gives (`/api/status`, `/api/health`), instead of four
27
+ fixed ones.
28
+ - A search during the startup pre-warm, or during the embeddings load
29
+ after an index, waits for that load (up to `embedding_timeout`) instead
30
+ of reporting that embeddings are loading. A streamed index reports the
31
+ embeddings line however long the load takes.
32
+ - scip-python runs without `pip` on PATH and outside a git repository: it
33
+ is given an empty environment and a project version.
34
+ - A SCIP indexer's failure is reported by its error, without the stack
35
+ frames that pushed it out of the message.
36
+ - `flatten` takes a class name as `context` does, not only a node ID.
37
+ - `impls` says trait implementations are recorded for Rust only, instead
38
+ of advising a reindex.
39
+ - An unresolved call with one candidate reads "unresolved; the only
40
+ candidate", not "one of 1 candidates".
41
+
9
42
  ## [0.3.0] — 2026-10-22
10
43
 
11
44
  Call resolution is rebuilt for every supported language: a call is
@@ -23,7 +23,7 @@ just test tests/test_handlers.py::test_index # one test
23
23
  just test -k "cross_lang and not slow" # by expression
24
24
  ```
25
25
 
26
- Python 3.11+ required (enforced by `pyproject.toml`). Pre-commit gate: `just lint && just fmt && just test` all pass (2722 tests currently — a drop is a regression signal).
26
+ Python 3.11+ required (enforced by `pyproject.toml`). Pre-commit gate: `just lint && just fmt && just test` all pass (2755 tests currently — a drop is a regression signal).
27
27
 
28
28
  Invoke the tool while developing:
29
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: descry-codegraph
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Codebase knowledge graph: polyglot indexer, semantic search, call-graph analysis
5
5
  Project-URL: Homepage, https://github.com/06chaynes/descry
6
6
  Project-URL: Repository, https://github.com/06chaynes/descry
@@ -251,7 +251,7 @@ Descry provides 19 tools, available through all interfaces:
251
251
  | `path` | Find shortest call path between two symbols |
252
252
  | `structure` | Show file structure — imports, classes, functions |
253
253
  | `flatten` | Show effective API of a class including inherited methods |
254
- | `impls` | Find all implementations of a trait/interface method |
254
+ | `impls` | Find all implementations of a Rust trait method |
255
255
  | `cross-lang` | Trace frontend API calls to backend handlers via OpenAPI |
256
256
  | `churn` | Find code churn hotspots (symbols, files, or co-change pairs) |
257
257
  | `evolution` | Track how a symbol has changed over time |
@@ -186,7 +186,7 @@ Descry provides 19 tools, available through all interfaces:
186
186
  | `path` | Find shortest call path between two symbols |
187
187
  | `structure` | Show file structure — imports, classes, functions |
188
188
  | `flatten` | Show effective API of a class including inherited methods |
189
- | `impls` | Find all implementations of a trait/interface method |
189
+ | `impls` | Find all implementations of a Rust trait method |
190
190
  | `cross-lang` | Trace frontend API calls to backend handlers via OpenAPI |
191
191
  | `churn` | Find code churn hotspots (symbols, files, or co-change pairs) |
192
192
  | `evolution` | Track how a symbol has changed over time |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "descry-codegraph"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Codebase knowledge graph: polyglot indexer, semantic search, call-graph analysis"
5
5
  requires-python = ">=3.11"
6
6
  authors = [{name = "Christian Haynes"}]
@@ -1,4 +1,4 @@
1
1
  """Descry — Codebase knowledge graph: polyglot indexer, semantic search, call-graph analysis."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
4
4
  __all__ = ["__version__"]
@@ -11,6 +11,11 @@ from pathlib import Path
11
11
 
12
12
  CURRENT_SCHEMA = 2
13
13
 
14
+ # The edges by which one symbol calls or constructs another: its callers
15
+ CALL_RELATIONS = frozenset(
16
+ {"CALLS", "CALLS_RESOLVED", "INSTANTIATES", "INSTANTIATES_RESOLVED"}
17
+ )
18
+
14
19
 
15
20
  class GraphSchemaError(Exception):
16
21
  """Raised when a graph file is not a graph this version can read.
@@ -368,7 +368,7 @@ def main():
368
368
 
369
369
  # impls
370
370
  sub = subparsers.add_parser(
371
- "impls", help="Find all implementations of a trait method"
371
+ "impls", help="Find all implementations of a Rust trait method"
372
372
  )
373
373
  sub.add_argument("method", help="Method name")
374
374
  sub.add_argument("--trait-name", default=None, help="Filter by trait name")
@@ -8388,15 +8388,6 @@ class PythonParser(BaseParser):
8388
8388
  return f"[{elts}]"
8389
8389
  return "Complex"
8390
8390
 
8391
- def _get_args_info(self, args_node):
8392
- args_list = []
8393
- for arg in args_node.args:
8394
- arg_str = arg.arg
8395
- if arg.annotation:
8396
- arg_str += f": {self._get_type_annotation(arg.annotation)}"
8397
- args_list.append(arg_str)
8398
- return args_list
8399
-
8400
8391
  def _get_param_types(self, args_node):
8401
8392
  """Extract parameter types as a list for metadata storage."""
8402
8393
  param_types = []
@@ -8492,12 +8483,14 @@ class PythonParser(BaseParser):
8492
8483
  is_method = parent_id in self.class_ids
8493
8484
  node_type = "Method" if is_method else "Function"
8494
8485
 
8495
- args = self._get_args_info(node.args)
8496
8486
  param_types = self._get_param_types(node.args)
8497
8487
  return_type = (
8498
- self._get_type_annotation(node.returns) if node.returns else "None"
8488
+ self._get_type_annotation(node.returns) if node.returns else ""
8499
8489
  )
8500
- signature = f"def {node.name}({', '.join(args)}) -> {return_type}"
8490
+ keyword = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
8491
+ signature = f"{keyword} {node.name}({ast.unparse(node.args)})"
8492
+ if node.returns:
8493
+ signature += f" -> {ast.unparse(node.returns)}"
8501
8494
 
8502
8495
  self.builder.add_node(
8503
8496
  func_id,
@@ -25440,16 +25433,19 @@ class CodeGraphBuilder:
25440
25433
  output_path: Path to write the graph JSON
25441
25434
  scip_index: Optional ScipIndex for type-aware resolution
25442
25435
  """
25443
- from descry._graph import CURRENT_SCHEMA
25436
+ from descry._graph import CALL_RELATIONS, CURRENT_SCHEMA
25444
25437
 
25445
25438
  self.resolve_references(scip_index=scip_index)
25446
- in_degree = {}
25439
+ # The number of its callers: those certain and those it is one of
25440
+ # the candidates of
25441
+ callers = {}
25447
25442
  for edge in self.edges:
25448
- t = edge["target"]
25449
- if t:
25450
- in_degree[t] = in_degree.get(t, 0) + 1
25443
+ if edge["relation"] in CALL_RELATIONS:
25444
+ candidates = edge.get("metadata", {}).get("candidates", ())
25445
+ for target in (edge["target"], *candidates):
25446
+ callers.setdefault(target, set()).add(edge["source"])
25451
25447
  for node in self.nodes:
25452
- node["metadata"]["in_degree"] = in_degree.get(node["id"], 0)
25448
+ node["metadata"]["in_degree"] = len(callers.get(node["id"], ()))
25453
25449
  # Atomic write: stream to a sibling tmp file then os.replace. A
25454
25450
  # concurrent reader (MCP prewarm, web `/api/health`, another CLI
25455
25451
  # invocation) that stats the destination mid-write would otherwise
@@ -1662,8 +1662,8 @@ class GitHistoryAnalyzer:
1662
1662
 
1663
1663
  # Show callers if requested
1664
1664
  if show_callers and self.querier:
1665
- sym_name = name.split(".")[-1] # Get base name
1666
- callers = self.querier.get_callers(sym_name)
1665
+ # The callers of this symbol, not of every one of its name
1666
+ callers = self.querier.get_callers(node_id)
1667
1667
  if callers:
1668
1668
  caller_names = []
1669
1669
  for caller_id in callers[:5]:
@@ -1742,8 +1742,7 @@ class GitHistoryAnalyzer:
1742
1742
  }
1743
1743
 
1744
1744
  if show_callers and self.querier:
1745
- sym_name = name.split(".")[-1]
1746
- caller_ids = self.querier.get_callers(sym_name)
1745
+ caller_ids = self.querier.get_callers(node_id)
1747
1746
  if caller_ids:
1748
1747
  callers = []
1749
1748
  for caller_id in caller_ids[:5]:
@@ -749,7 +749,9 @@ def format_search_result(
749
749
 
750
750
  score_str = f"[{score:.2f}] " if show_score else ""
751
751
  metrics_str = (
752
- f"({token_count} toks, {in_degree} callers)" if token_count or in_degree else ""
752
+ f"({token_count} toks, {in_degree} caller{'' if in_degree == 1 else 's'})"
753
+ if token_count or in_degree
754
+ else ""
753
755
  )
754
756
 
755
757
  lines = [f"{score_str}[{node_type}] {name} | {location} {metrics_str}".rstrip()]
@@ -1050,17 +1052,7 @@ class DescryService:
1050
1052
  self._git_cache_lock = asyncio.Lock()
1051
1053
  self._dedup_cache_lock = asyncio.Lock()
1052
1054
 
1053
- self._graph_cache = {"mtime": 0, "nodes": 0, "edges": 0, "error": None}
1054
- self._querier_cache = {"mtime": 0, "instance": None, "error": None}
1055
- self._semantic_cache = {
1056
- "mtime": 0,
1057
- "instance": None,
1058
- "loading": False,
1059
- "error": None,
1060
- }
1061
- self._semantic_load = None
1062
- self._git_cache = {"analyzer": None, "graph_mtime": None}
1063
- self._dedup_cache: dict[str, tuple[float, str]] = {}
1055
+ self.reset_caches()
1064
1056
  self._max_dedup_entries = 100
1065
1057
 
1066
1058
  # Index runs write the same cache directory, so one runs at a time
@@ -1095,6 +1087,7 @@ class DescryService:
1095
1087
  "mtime": mtime,
1096
1088
  "nodes": len(data.get("nodes", [])),
1097
1089
  "edges": len(data.get("edges", [])),
1090
+ "languages": self._graph_languages(data),
1098
1091
  "error": None,
1099
1092
  }
1100
1093
  # ValueError: invalid JSON or invalid UTF-8.
@@ -1105,9 +1098,24 @@ class DescryService:
1105
1098
  "mtime": mtime,
1106
1099
  "nodes": 0,
1107
1100
  "edges": 0,
1101
+ "languages": [],
1108
1102
  "error": str(e),
1109
1103
  }
1110
1104
 
1105
+ def _graph_languages(self, data) -> list[str]:
1106
+ """The languages of the graph's nodes, as the `lang` search filter
1107
+ names them and finds them: by the extension of the file a node is
1108
+ in."""
1109
+ if self._get_syntax_lang is None:
1110
+ return []
1111
+ paths = {
1112
+ node["id"][5:].split("::")[0]
1113
+ for node in data.get("nodes", [])
1114
+ if node.get("id", "").startswith("FILE:")
1115
+ }
1116
+ langs = {self._get_syntax_lang(p, self.config.syntax_lang_map) for p in paths}
1117
+ return sorted(langs - {""})
1118
+
1111
1119
  async def _get_querier(self):
1112
1120
  """The cached GraphQuerier; None if the graph is missing or unloadable."""
1113
1121
  if self._GraphQuerier is None:
@@ -1174,17 +1182,24 @@ class DescryService:
1174
1182
  self._dedup_cache[content_hash] = (graph_mtime, node_id)
1175
1183
 
1176
1184
  def reset_caches(self):
1177
- """Reset all cached instances. Call after reindex."""
1178
- self._graph_cache = {"mtime": 0, "nodes": 0, "edges": 0, "error": None}
1185
+ """Reset all cached instances. Called on construction and after a
1186
+ reindex."""
1187
+ self._graph_cache = {
1188
+ "mtime": 0,
1189
+ "nodes": 0,
1190
+ "edges": 0,
1191
+ "languages": [],
1192
+ "error": None,
1193
+ }
1179
1194
  self._querier_cache = {"mtime": 0, "instance": None, "error": None}
1180
1195
  self._semantic_cache = {
1181
1196
  "mtime": 0,
1182
1197
  "instance": None,
1183
- "loading": False,
1198
+ "load": None,
1184
1199
  "error": None,
1185
1200
  }
1186
1201
  self._git_cache = {"analyzer": None, "graph_mtime": None}
1187
- self._dedup_cache = {}
1202
+ self._dedup_cache: dict[str, tuple[float, str]] = {}
1188
1203
  # The module-level file-content cache in query.py is keyed on mtime,
1189
1204
  # so it self-invalidates after an edit; but a reindex can shift file
1190
1205
  # contents even without an mtime bump (copies, git checkouts). Clear
@@ -1229,55 +1244,93 @@ class DescryService:
1229
1244
  source_root=str(self.config.project_root),
1230
1245
  )
1231
1246
 
1247
+ def _start_semantic_load(self, cache, build):
1248
+ """Run ``build`` in a thread as the load of ``cache``, which holds
1249
+ what it builds, or the error it raises, as of the graph it began
1250
+ on. Called holding the lock; returns the task."""
1251
+ mtime = self._graph_mtime() or 0
1252
+
1253
+ async def load():
1254
+ try:
1255
+ # No timeout: the thread cannot be stopped, so a load given
1256
+ # up on would go on while another started.
1257
+ searcher = await asyncio.to_thread(build)
1258
+ async with self._semantic_cache_lock:
1259
+ cache.update(mtime=mtime, instance=searcher, error=None)
1260
+ return searcher
1261
+ except Exception as e: # noqa: BLE001 — boundary over sentence-transformers/torch model load; error is cached and logged
1262
+ async with self._semantic_cache_lock:
1263
+ cache["error"] = str(e)
1264
+ logger.warning(f"Embeddings load failed: {e}")
1265
+ return None
1266
+ finally:
1267
+ async with self._semantic_cache_lock:
1268
+ cache["load"] = None
1269
+
1270
+ cache["load"] = asyncio.create_task(load())
1271
+ return cache["load"]
1272
+
1232
1273
  async def _load_embeddings_background(self):
1274
+ """Load the searcher and its model, so the first search pays for
1275
+ neither."""
1276
+
1277
+ def build():
1278
+ searcher = self._new_searcher()
1279
+ # On a warm cache the constructor never touches the model.
1280
+ searcher.ensure_model()
1281
+ return searcher
1282
+
1233
1283
  async with self._semantic_cache_lock:
1234
- # A reindex replaces the cache, so a load writes only to its own.
1235
1284
  cache = self._semantic_cache
1236
- if cache["instance"] is not None or cache["loading"]:
1285
+ if cache["instance"] is not None:
1237
1286
  return
1238
- cache["loading"] = True
1239
- mtime = self._graph_mtime() or 0
1240
-
1241
- try:
1242
-
1243
- def load_sync():
1244
- searcher = self._new_searcher()
1245
- # On a warm cache the constructor never touches the model, so
1246
- # without this the pre-warm reports "ready" while the first
1247
- # real query still pays the full model load.
1287
+ load = cache["load"] or self._start_semantic_load(cache, build)
1288
+ if await asyncio.shield(load) is not None:
1289
+ logger.info("Pre-warm: embeddings ready")
1290
+
1291
+ async def _load_indexed_embeddings(self, model_was_loaded):
1292
+ """Load the embeddings of a graph just indexed, and the model where
1293
+ it was loaded before; returns the line on them for the index's
1294
+ summary, empty without embeddings."""
1295
+ if not (
1296
+ self._semantic_available
1297
+ and self._SemanticSearcher
1298
+ and self.config.graph_path.exists()
1299
+ ):
1300
+ return ""
1301
+ logger.info("Loading embeddings for semantic search...")
1302
+
1303
+ def build():
1304
+ # The child process has just embedded this graph and cached the
1305
+ # vectors under a key derived from the graph's content, so this
1306
+ # loads them. It encodes only when the child could not, since no
1307
+ # cache then matches.
1308
+ searcher = self._new_searcher()
1309
+ # Loading from cache never touches the model. A server that had
1310
+ # it loaded keeps it loaded, so the first search after a reindex
1311
+ # does not pay for the load; a one-shot `descry index` skips it.
1312
+ if model_was_loaded:
1248
1313
  searcher.ensure_model()
1249
- return searcher
1314
+ return searcher
1250
1315
 
1251
- # No timeout: the thread cannot be stopped, so a load given up on
1252
- # would go on building the cache while a search started another.
1253
- searcher = await asyncio.to_thread(load_sync)
1254
-
1255
- async with self._semantic_cache_lock:
1256
- cache["mtime"] = mtime
1257
- cache["instance"] = searcher
1258
- cache["error"] = None
1259
- logger.info("Pre-warm: embeddings ready")
1260
- except Exception as e: # noqa: BLE001 — boundary over sentence-transformers/torch model load; error is cached and logged
1261
- async with self._semantic_cache_lock:
1262
- cache["error"] = str(e)
1263
- logger.warning(f"Pre-warm: embeddings failed: {e}")
1264
- finally:
1265
- async with self._semantic_cache_lock:
1266
- cache["loading"] = False
1316
+ async with self._semantic_cache_lock:
1317
+ cache = self._semantic_cache
1318
+ load = cache["load"] or self._start_semantic_load(cache, build)
1319
+ searcher = await asyncio.shield(load)
1320
+ if searcher is None:
1321
+ return f"Embeddings: Failed ({cache['error']})"
1322
+ return f"Embeddings: {len(searcher.nodes):,} nodes indexed"
1267
1323
 
1268
1324
  async def _get_semantic_searcher(self):
1269
1325
  """Single-flight accessor for the semantic searcher.
1270
1326
 
1271
- Returns the cached SemanticSearcher, rebuilding if
1272
- codebase_graph.json mtime changed. Returns None if embeddings are
1273
- unavailable (module missing or disabled), if the graph file does
1274
- not exist, or while a load is not done.
1275
-
1276
- Uses _semantic_cache_lock; construction runs in asyncio.to_thread so
1277
- the event loop is not blocked by model load. Callers that want to
1278
- surface an "embeddings loading" UX string should check
1279
- _semantic_cache["error"] when this returns None: it is the last
1280
- failed load's until a load succeeds.
1327
+ Returns the cached SemanticSearcher, loading it again if
1328
+ codebase_graph.json mtime changed, and waits up to
1329
+ `embedding_timeout` for a load under way. Returns None if
1330
+ embeddings are unavailable (module missing or disabled), if the
1331
+ graph file does not exist, or while a load is not done; then
1332
+ _semantic_cache["error"] is the last failed load's until a load
1333
+ succeeds.
1281
1334
  """
1282
1335
  if not self._semantic_available or self._SemanticSearcher is None:
1283
1336
  return None
@@ -1289,34 +1342,10 @@ class DescryService:
1289
1342
  cache = self._semantic_cache
1290
1343
  if cache["instance"] is not None and cache["mtime"] == mtime:
1291
1344
  return cache["instance"]
1292
- if cache["loading"]:
1293
- return None
1294
- cache["loading"] = True
1295
-
1296
- async def load():
1297
- try:
1298
- searcher = await asyncio.to_thread(self._new_searcher)
1299
- async with self._semantic_cache_lock:
1300
- cache["mtime"] = mtime
1301
- cache["instance"] = searcher
1302
- cache["error"] = None
1303
- return searcher
1304
- except Exception as e: # noqa: BLE001 — boundary over sentence-transformers/torch model load; error is cached and returned
1305
- async with self._semantic_cache_lock:
1306
- cache["error"] = str(e)
1307
- logger.warning(f"Embeddings load failed: {e}")
1308
- return None
1309
- finally:
1310
- async with self._semantic_cache_lock:
1311
- cache["loading"] = False
1312
-
1313
- # The thread cannot be stopped: a load not done in time goes on and
1314
- # caches what it builds, and a search meanwhile is told it is loading.
1315
- self._semantic_load = asyncio.create_task(load())
1345
+ load = cache["load"] or self._start_semantic_load(cache, self._new_searcher)
1316
1346
  try:
1317
1347
  return await asyncio.wait_for(
1318
- asyncio.shield(self._semantic_load),
1319
- timeout=self.config.embedding_timeout,
1348
+ asyncio.shield(load), timeout=self.config.embedding_timeout
1320
1349
  )
1321
1350
  except TimeoutError:
1322
1351
  logger.warning(
@@ -1394,7 +1423,9 @@ class DescryService:
1394
1423
  health["features"]["embeddings"] = (
1395
1424
  self._semantic_cache["instance"] is not None
1396
1425
  )
1397
- health["features"]["embeddings_loading"] = self._semantic_cache["loading"]
1426
+ health["features"]["embeddings_loading"] = (
1427
+ self._semantic_cache["load"] is not None
1428
+ )
1398
1429
  health["features"]["embeddings_error"] = self._semantic_cache.get("error")
1399
1430
 
1400
1431
  if not self._semantic_available:
@@ -1439,6 +1470,7 @@ class DescryService:
1439
1470
  "age_hours": round(age_hours, 2),
1440
1471
  "nodes": self._graph_cache["nodes"],
1441
1472
  "edges": self._graph_cache["edges"],
1473
+ "languages": self._graph_cache["languages"],
1442
1474
  "stale": age_hours > self.config.max_stale_hours,
1443
1475
  "error": self._graph_cache["error"],
1444
1476
  }
@@ -1665,56 +1697,8 @@ class DescryService:
1665
1697
  f"\nSCIP: {len(scip_files)} project(s) indexed"
1666
1698
  )
1667
1699
 
1668
- embeddings_status = ""
1669
- if (
1670
- self._semantic_available
1671
- and self._SemanticSearcher
1672
- and self.config.graph_path.exists()
1673
- ):
1674
- try:
1675
- # Reset the cache, then construct in a thread so the
1676
- # event loop is not blocked.
1677
- async with self._semantic_cache_lock:
1678
- cache = self._semantic_cache = {
1679
- "mtime": 0,
1680
- "instance": None,
1681
- "loading": True,
1682
- "error": None,
1683
- }
1684
- logger.info("Generating embeddings for semantic search...")
1685
- mtime = self._graph_mtime() or 0
1686
-
1687
- def _build_searcher():
1688
- # The child process has just embedded this graph
1689
- # and cached the vectors under a key derived from
1690
- # the graph's content, so this loads them. It
1691
- # encodes only when the child could not, since no
1692
- # cache then matches.
1693
- searcher = self._new_searcher()
1694
- # Loading from cache never touches the model. A
1695
- # server that had it loaded keeps it loaded, so
1696
- # the first search after a reindex does not pay
1697
- # for the load; a one-shot `descry index` skips it.
1698
- if model_was_loaded:
1699
- searcher.ensure_model()
1700
- return searcher
1701
-
1702
- searcher = await asyncio.to_thread(_build_searcher)
1703
- # Seed the cache with the freshly-built instance so
1704
- # the next search() call reuses it.
1705
- async with self._semantic_cache_lock:
1706
- cache["mtime"] = mtime
1707
- cache["instance"] = searcher
1708
- embeddings_status = (
1709
- f"\nEmbeddings: {len(searcher.nodes):,} nodes indexed"
1710
- )
1711
- except Exception as e: # noqa: BLE001 — boundary over sentence-transformers/torch; reported in the index summary
1712
- embeddings_status = f"\nEmbeddings: Failed ({e})"
1713
- logger.warning(f"Embeddings generation failed: {e}")
1714
- finally:
1715
- async with self._semantic_cache_lock:
1716
- cache["loading"] = False
1717
-
1700
+ embeddings = await self._load_indexed_embeddings(model_was_loaded)
1701
+ embeddings_status = f"\n{embeddings}" if embeddings else ""
1718
1702
  return f"Index complete.{scip_status}{embeddings_status}\n{output}"
1719
1703
  else:
1720
1704
  return ServiceError(f"Index failed:\n{stderr}")
@@ -2584,16 +2568,8 @@ class DescryService:
2584
2568
  return
2585
2569
 
2586
2570
  self.reset_caches()
2587
- # The child has cached this graph's vectors, so this loads them.
2588
- searcher = await self._get_semantic_searcher()
2589
- if searcher is not None:
2590
- line = f"Embeddings: {len(searcher.nodes):,} nodes indexed"
2591
- if model_was_loaded:
2592
- try:
2593
- await asyncio.to_thread(searcher.ensure_model)
2594
- except Exception as e: # noqa: BLE001 — boundary over sentence-transformers/torch; reported in the stream
2595
- line = f"Embeddings: Failed ({e})"
2596
- yield {"type": "output", "line": line}
2571
+ if embeddings := await self._load_indexed_embeddings(model_was_loaded):
2572
+ yield {"type": "output", "line": embeddings}
2597
2573
  yield {"type": "done", "success": True, "message": "Index complete."}
2598
2574
  finally:
2599
2575
  self._index_lock.release()
@@ -2619,9 +2595,8 @@ class DescryService:
2619
2595
  if not results:
2620
2596
  trait_filter = f" for trait '{trait_name}'" if trait_name else ""
2621
2597
  return (
2622
- f"No trait implementations found for method '{method}'{trait_filter}.\n\n"
2623
- f"Note: trait_impl metadata is only available for recently indexed code. "
2624
- f"Run descry index to regenerate the graph with this metadata."
2598
+ f"No trait implementations found for method '{method}'{trait_filter}. "
2599
+ "Trait implementations are recorded for Rust code only."
2625
2600
  )
2626
2601
 
2627
2602
  by_trait = defaultdict(list)
@@ -218,7 +218,7 @@ async def descry_context(
218
218
  head_lines: int | None = None,
219
219
  max_output_tokens: int | None = None,
220
220
  ) -> str:
221
- """Get full context for a symbol: source code, callers, callees, tests (~500+ tokens). Use brief=true (~50 tokens) to quickly verify a symbol before fetching full context. Options: full=true (no truncation), expand_callees=true (inline dependencies), head_lines=N (preview first N lines)."""
221
+ """Get full context for a symbol: source code, callers, callees, tests (~500+ tokens). Use brief=true (~50 tokens) to quickly verify a symbol before fetching full context. Options: full=true (no truncation), expand_callees=true (inline dependencies), head_lines=N (preview first N lines). node_id takes a node ID or a symbol name."""
222
222
  return _fenced(
223
223
  await _svc().context(
224
224
  node_id,
@@ -281,7 +281,7 @@ async def descry_structure(filename: str) -> str:
281
281
 
282
282
  @mcp.tool()
283
283
  async def descry_flatten(class_node_id: str) -> str:
284
- """Show the effective API of a class including inherited methods. Use for understanding class hierarchies in OOP codebases."""
284
+ """Show the effective API of a class including inherited methods. Use for understanding class hierarchies in OOP codebases. class_node_id takes a node ID or a class name."""
285
285
  return _fenced(await _svc().flatten(class_node_id))
286
286
 
287
287
 
@@ -308,7 +308,7 @@ async def descry_quick(name: str, full: bool = False, brief: bool = False) -> st
308
308
 
309
309
  @mcp.tool()
310
310
  async def descry_impls(method: str, trait_name: str | None = None) -> str:
311
- """Find all implementations of a trait method across the codebase. Use when you know a trait method name (e.g., 'from_request_parts') but need to find which types implement it. Optionally filter by trait name."""
311
+ """Find all implementations of a Rust trait method across the codebase. Use when you know a trait method name (e.g., 'from_request_parts') but need to find which types implement it. Optionally filter by trait name."""
312
312
  return _fenced(await _svc().impls(method, trait_name))
313
313
 
314
314