descry-codegraph 0.3.3__tar.gz → 0.3.4__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.3 → descry_codegraph-0.3.4}/CHANGELOG.md +15 -0
  2. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/CLAUDE.md +1 -1
  3. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/PKG-INFO +1 -1
  4. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/claude-skill/descry/references/cli-reference.md +2 -2
  5. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/pyproject.toml +1 -1
  6. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/__init__.py +1 -1
  7. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/cli.py +10 -2
  8. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/embeddings.py +14 -2
  9. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/handlers.py +4 -4
  10. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/query.py +36 -36
  11. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_cli.py +26 -0
  12. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_embeddings.py +20 -0
  13. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_handlers.py +38 -0
  14. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_query.py +14 -2
  15. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/.github/assets/01-landing.png +0 -0
  16. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/.github/workflows/ci.yml +0 -0
  17. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/.github/workflows/release.yml +0 -0
  18. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/.gitignore +0 -0
  19. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/LICENSE +0 -0
  20. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/README.md +0 -0
  21. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/SECURITY.md +0 -0
  22. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/THIRD-PARTY-NOTICES.md +0 -0
  23. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/claude-skill/README.md +0 -0
  24. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/claude-skill/descry/SKILL.md +0 -0
  25. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/claude-skill/descry/references/mcp-tools.md +0 -0
  26. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/claude-skill/descry/references/recipes.md +0 -0
  27. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/justfile +0 -0
  28. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/pi-extension/README.md +0 -0
  29. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/pi-extension/descry-cli.py +0 -0
  30. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/pi-extension/descry-tools.ts +0 -0
  31. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/_comments.py +0 -0
  32. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/_env.py +0 -0
  33. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/_files.py +0 -0
  34. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/_graph.py +0 -0
  35. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/_stdlib.py +0 -0
  36. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/clang_parser.py +0 -0
  37. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/cross_lang.py +0 -0
  38. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/dart_parser.py +0 -0
  39. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/dotnet_parser.py +0 -0
  40. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/generate.py +0 -0
  41. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/git_history.py +0 -0
  42. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/go_parser.py +0 -0
  43. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/java_parser.py +0 -0
  44. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/mcp_server.py +0 -0
  45. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/php_parser.py +0 -0
  46. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/ruby_parser.py +0 -0
  47. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/__init__.py +0 -0
  48. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapter.py +0 -0
  49. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/__init__.py +0 -0
  50. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/_empty_environment.json +0 -0
  51. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/_java_compat.init.gradle +0 -0
  52. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/clang.py +0 -0
  53. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/dart.py +0 -0
  54. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/dotnet.py +0 -0
  55. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/go.py +0 -0
  56. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/java.py +0 -0
  57. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/php.py +0 -0
  58. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/python.py +0 -0
  59. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/ruby.py +0 -0
  60. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/rust.py +0 -0
  61. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/adapters/typescript.py +0 -0
  62. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/cache.py +0 -0
  63. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/parser.py +0 -0
  64. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/pb2.py +0 -0
  65. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/scip/support.py +0 -0
  66. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/c.json +0 -0
  67. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/cpp.json +0 -0
  68. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/dart.json +0 -0
  69. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/dotnet.json +0 -0
  70. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/go.json +0 -0
  71. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/java.json +0 -0
  72. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/node.json +0 -0
  73. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/php.json +0 -0
  74. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/python.json +0 -0
  75. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/ruby.json +0 -0
  76. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/rust.json +0 -0
  77. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/stdlib/typescript.json +0 -0
  78. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/web/__init__.py +0 -0
  79. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/web/server.py +0 -0
  80. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/web/web/index.html +0 -0
  81. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/src/descry/web/web/style.css +0 -0
  82. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/__init__.py +0 -0
  83. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/conftest.py +0 -0
  84. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/README.md +0 -0
  85. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/__init__.py +0 -0
  86. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/compare.py +0 -0
  87. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/corpus.py +0 -0
  88. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/metrics.py +0 -0
  89. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/registry.py +0 -0
  90. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/results/results-descry.json +0 -0
  91. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/eval/results/results-ripgrep.json +0 -0
  92. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/fixtures/small_graph.json +0 -0
  93. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_backtracking.py +0 -0
  94. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_clang_parser.py +0 -0
  95. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_config.py +0 -0
  96. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_cross_lang.py +0 -0
  97. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_dart_parser.py +0 -0
  98. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_dotnet_parser.py +0 -0
  99. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_generate.py +0 -0
  100. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_git_history.py +0 -0
  101. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_go_parser.py +0 -0
  102. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_hardening.py +0 -0
  103. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_interfaces.py +0 -0
  104. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_java_parser.py +0 -0
  105. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_mcp.py +0 -0
  106. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_non_regular_files.py +0 -0
  107. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_php_parser.py +0 -0
  108. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_ruby_parser.py +0 -0
  109. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip.py +0 -0
  110. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_cache.py +0 -0
  111. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_clang.py +0 -0
  112. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_dart.py +0 -0
  113. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_dotnet.py +0 -0
  114. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_go.py +0 -0
  115. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_java.py +0 -0
  116. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_php.py +0 -0
  117. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_scip_ruby.py +0 -0
  118. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_stdlib.py +0 -0
  119. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_web_server.py +0 -0
  120. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tests/test_written_through.py +0 -0
  121. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/README.md +0 -0
  122. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/common.py +0 -0
  123. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_c.py +0 -0
  124. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_cpp.py +0 -0
  125. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_dart.py +0 -0
  126. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_dotnet.py +0 -0
  127. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_go.py +0 -0
  128. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_java.py +0 -0
  129. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_node.py +0 -0
  130. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_php.py +0 -0
  131. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_python.py +0 -0
  132. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_ruby.py +0 -0
  133. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_rust.py +0 -0
  134. {descry_codegraph-0.3.3 → descry_codegraph-0.3.4}/tools/stdlib/gen_typescript.py +0 -0
@@ -6,6 +6,21 @@ 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.4] — 2026-10-23
10
+
11
+ ### Fixed
12
+
13
+ - `search` answers what semantic search finds when the keywords find
14
+ nothing: the semantic pass ran because they found little, and its
15
+ results were then dropped ("No matches"). It now searches under
16
+ `lang`, `crate`, `type` and `exclude_tests` as the keyword side does; its
17
+ results were merged unfiltered.
18
+ - `search --type` with a type the graph has none of, or one descry does
19
+ not know, matches nothing, as an unknown `lang` does; it was ignored and
20
+ every type answered. The CLI lists the types it takes.
21
+ - `flow --target` takes a node ID as well as a name; a node ID was never
22
+ found, and the trace said nothing of it.
23
+
9
24
  ## [0.3.3] — 2026-10-23
10
25
 
11
26
  ### Fixed
@@ -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 (2812 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 (2822 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.3
3
+ Version: 0.3.4
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
@@ -24,7 +24,7 @@ descry search <terms...>
24
24
  [--limit N] # default 10
25
25
  [--lang rust|ts|py|...]
26
26
  [--crate NAME]
27
- [--type Function|Class|Method|...]
27
+ [--type function|class|method|constant|file|all]
28
28
  [--exclude-tests]
29
29
 
30
30
  descry semantic <query>
@@ -49,7 +49,7 @@ descry callees <name> [--limit N] # default 20
49
49
  descry flow <start>
50
50
  [--direction forward|backward] # default forward
51
51
  [--depth N] # default 3, capped by [query] max_depth
52
- [--target NAME] # stop early if reached
52
+ [--target SYMBOL] # stop early if reached (name or node ID)
53
53
  [--inline-threshold N] # inline callee source below N tokens
54
54
 
55
55
  descry path <start> <end>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "descry-codegraph"
3
- version = "0.3.3"
3
+ version = "0.3.4"
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.3"
3
+ __version__ = "0.3.4"
4
4
  __all__ = ["__version__"]
@@ -308,7 +308,9 @@ def main():
308
308
  default=3,
309
309
  help="Levels of calls below the start (default: 3)",
310
310
  )
311
- sub.add_argument("--target", default=None, help="Target symbol to reach")
311
+ sub.add_argument(
312
+ "--target", default=None, help="Symbol to reach, by name or node ID"
313
+ )
312
314
  sub.add_argument(
313
315
  "--inline-threshold",
314
316
  type=int,
@@ -329,7 +331,13 @@ def main():
329
331
  sub.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
330
332
  sub.add_argument("--lang", default=None, help="Filter by language")
331
333
  sub.add_argument("--crate", default=None, help="Filter by crate")
332
- sub.add_argument("--type", default=None, help="Filter by symbol type")
334
+ sub.add_argument(
335
+ "--type",
336
+ default=None,
337
+ type=str.lower,
338
+ choices=["function", "class", "method", "constant", "file", "all"],
339
+ help="Filter by symbol type",
340
+ )
333
341
  sub.add_argument(
334
342
  "--exclude-tests", action="store_true", help="Exclude test symbols"
335
343
  )
@@ -774,7 +774,9 @@ class SemanticSearcher:
774
774
  self.embeddings = embeddings / np.where(norms == 0, 1, norms)
775
775
  logger.info("Embeddings generated successfully")
776
776
 
777
- def search(self, query: str, limit: int = 10, min_score: float = 0.3) -> list:
777
+ def search(
778
+ self, query: str, limit: int = 10, min_score: float = 0.3, keep=None
779
+ ) -> list:
778
780
  """Search for nodes semantically similar to the query.
779
781
 
780
782
  Args:
@@ -782,6 +784,7 @@ class SemanticSearcher:
782
784
  limit: Maximum number of results
783
785
  min_score: Minimum final score, i.e. cosine similarity after
784
786
  the in-degree and type boosts are added (may exceed 1).
787
+ keep: The node IDs results may have, None for any.
785
788
 
786
789
  Returns:
787
790
  List of (node, score) tuples sorted by relevance
@@ -826,10 +829,19 @@ class SemanticSearcher:
826
829
  np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_embedding)
827
830
  )
828
831
 
832
+ if keep is not None:
833
+ # Nodes outside `keep` never enter the pool
834
+ kept = np.fromiter(
835
+ (n["id"] in keep for n in self.nodes), bool, len(self.nodes)
836
+ )
837
+ similarities = np.where(kept, similarities, -np.inf)
838
+ if not kept.any():
839
+ return []
840
+
829
841
  # Take a candidate pool by base score, then apply min_score to the
830
842
  # final score, so a node whose boosts (worth up to +0.17) lift it over
831
843
  # the threshold is kept.
832
- pool = min(len(similarities), limit * 3)
844
+ pool = min(len(similarities) if keep is None else int(kept.sum()), limit * 3)
833
845
  top_indices = np.argpartition(-similarities, pool - 1)[:pool]
834
846
  top_indices = top_indices[np.argsort(-similarities[top_indices])]
835
847
 
@@ -2086,23 +2086,23 @@ class DescryService:
2086
2086
  # and respects the cache lock).
2087
2087
  searcher = await self._get_semantic_searcher()
2088
2088
  if searcher is not None:
2089
+ # Under the filters the keyword side has
2089
2090
  semantic_results = await asyncio.to_thread(
2090
2091
  searcher.search,
2091
2092
  " ".join(terms),
2092
2093
  limit=limit * 2,
2093
2094
  min_score=0.25,
2095
+ keep=q.filtered_ids(lang, crate, symbol_type, exclude_tests),
2094
2096
  )
2095
2097
  except Exception as e: # noqa: BLE001 — boundary over the embedding backend; search degrades to keyword-only
2096
2098
  logger.warning(f"Semantic search failed, using keyword only: {e}")
2097
2099
 
2098
- if semantic_results and tfidf_results:
2100
+ if semantic_results:
2099
2101
  combined = reciprocal_rank_fusion(tfidf_results, semantic_results)
2100
2102
  results = [node for node, _ in combined[:limit]]
2101
2103
  method = "hybrid"
2102
- elif tfidf_results:
2103
- results = tfidf_results[:limit]
2104
2104
  else:
2105
- results = []
2105
+ results = tfidf_results[:limit]
2106
2106
 
2107
2107
  return {"ok": True, "error": None, "results": results, "method": method}
2108
2108
 
@@ -1318,7 +1318,8 @@ class GraphQuerier:
1318
1318
  once, at the first level that reaches it, and met again as `seen`,
1319
1319
  the level of that entry (the start is level 0).
1320
1320
  Returns ``(root, path, cut)``: the tree, the node IDs from the start
1321
- to the first node named ``target``, and why it stopped early
1321
+ to the first node ``target`` names, by name or node ID, and why it
1322
+ stopped early
1322
1323
  ("timeout", "node limit") or None."""
1323
1324
  start_time = time.time()
1324
1325
 
@@ -1353,7 +1354,7 @@ class GraphQuerier:
1353
1354
 
1354
1355
  root = tree_node(start_id, None)
1355
1356
  paths = {start_id: [start_id]}
1356
- if target and root["name"] == target:
1357
+ if target in (root["name"], root["id"]):
1357
1358
  return root, paths[start_id], None
1358
1359
  level = [root]
1359
1360
  for _ in range(depth):
@@ -1378,7 +1379,7 @@ class GraphQuerier:
1378
1379
  child = tree_node(child_id, marks.get(child_id))
1379
1380
  paths[child_id] = [*paths[parent["id"]], child_id]
1380
1381
  parent["children"].append(child)
1381
- if target and child["name"] == target:
1382
+ if target in (child["name"], child["id"]):
1382
1383
  return root, paths[child_id], None
1383
1384
  next_level.append(child)
1384
1385
  level = next_level
@@ -1500,7 +1501,7 @@ class GraphQuerier:
1500
1501
  cut (why the trace stopped early, when it did). Each tree node has: id,
1501
1502
  name, type, tokens, file, line, code, lang, candidates, children,
1502
1503
  more, more_possible, and `seen`, the level its entry is at, where it
1503
- is placed there already. The trace stops at the first node named `target`.
1504
+ is placed there already. The trace stops at the first node `target` names, by name or node ID.
1504
1505
  """
1505
1506
  if timeout_ms is None:
1506
1507
  timeout_ms = self._timeout_ms
@@ -1666,6 +1667,33 @@ class GraphQuerier:
1666
1667
  idf[term] = math.log((total_docs + 1) / (df + 1)) + 1
1667
1668
  return idf
1668
1669
 
1670
+ def filtered_ids(
1671
+ self, lang=None, crate=None, symbol_type=None, exclude_tests=False
1672
+ ):
1673
+ """The IDs of the nodes `search_docs`'s filters keep, None when none
1674
+ is given: the language, the crate or module, the type, and no tests."""
1675
+ if not (lang or crate or symbol_type or exclude_tests):
1676
+ return None
1677
+ self._ensure_filter_indices()
1678
+ kept = set(self.nodes.keys())
1679
+ if exclude_tests and self._index_is_test:
1680
+ kept -= self._index_is_test
1681
+ if crate:
1682
+ kept &= self._index_by_crate.get(crate, set())
1683
+ if lang and lang != "all":
1684
+ lang = _LANG_ALIASES.get(lang.lower(), lang.lower())
1685
+ kept &= self._index_by_lang.get(lang, set())
1686
+ if symbol_type and symbol_type != "all":
1687
+ type_map = {
1688
+ "function": "Function",
1689
+ "class": "Class",
1690
+ "method": "Method",
1691
+ "constant": "Constant",
1692
+ "file": "File",
1693
+ }
1694
+ kept &= self._index_by_type.get(type_map.get(symbol_type.lower()), set())
1695
+ return kept
1696
+
1669
1697
  def search_docs(
1670
1698
  self, query_terms, lang=None, crate=None, symbol_type=None, exclude_tests=False
1671
1699
  ):
@@ -1695,38 +1723,10 @@ class GraphQuerier:
1695
1723
  if num_terms == 0:
1696
1724
  return []
1697
1725
 
1698
- # Apply filters if any specified
1699
- if lang or crate or symbol_type or exclude_tests:
1700
- self._ensure_filter_indices()
1701
- candidate_ids = set(self.nodes.keys())
1702
-
1703
- # Filter out test files and functions if requested
1704
- if exclude_tests and self._index_is_test:
1705
- candidate_ids -= self._index_is_test
1706
-
1707
- if crate:
1708
- # If crate filter specified but doesn't exist, return empty results
1709
- if crate not in self._index_by_crate:
1710
- return []
1711
- candidate_ids &= self._index_by_crate[crate]
1712
- if lang and lang != "all":
1713
- lang = _LANG_ALIASES.get(lang.lower(), lang.lower())
1714
- candidate_ids &= self._index_by_lang.get(lang, set())
1715
- if symbol_type and symbol_type != "all":
1716
- type_map = {
1717
- "function": "Function",
1718
- "class": "Class",
1719
- "method": "Method",
1720
- "constant": "Constant",
1721
- "file": "File",
1722
- }
1723
- type_key = type_map.get(symbol_type.lower())
1724
- if type_key and type_key in self._index_by_type:
1725
- candidate_ids &= self._index_by_type[type_key]
1726
-
1727
- candidates = [self.nodes[nid] for nid in candidate_ids]
1728
- else:
1729
- candidates = self.data["nodes"]
1726
+ kept = self.filtered_ids(lang, crate, symbol_type, exclude_tests)
1727
+ candidates = (
1728
+ self.data["nodes"] if kept is None else [self.nodes[nid] for nid in kept]
1729
+ )
1730
1730
 
1731
1731
  for node in candidates:
1732
1732
  score = 0.0
@@ -225,3 +225,29 @@ class TestCliStopped:
225
225
  index.send_signal(signal.SIGHUP)
226
226
  assert not indexer_tree.grandchild_exited(timeout=1)
227
227
  assert index.poll() is None
228
+
229
+
230
+ class TestSearchType:
231
+ """`--type` takes the types descry has, in any case, and refuses others."""
232
+
233
+ def test_a_type_is_passed_lowercased(self, monkeypatch):
234
+ seen = {}
235
+
236
+ class Service:
237
+ async def search(self, **kwargs):
238
+ seen.update(kwargs)
239
+ return "ok"
240
+
241
+ monkeypatch.setattr(cli, "_make_service", Service)
242
+ monkeypatch.setattr(
243
+ sys, "argv", ["descry", "search", "x", "--type", "Function"]
244
+ )
245
+ cli.main()
246
+ assert seen["symbol_type"] == "function"
247
+
248
+ def test_an_unknown_type_is_refused(self, monkeypatch, capsys):
249
+ monkeypatch.setattr(sys, "argv", ["descry", "search", "x", "--type", "struct"])
250
+ with pytest.raises(SystemExit) as exit_:
251
+ cli.main()
252
+ assert exit_.value.code == 2
253
+ assert "invalid choice: 'struct'" in capsys.readouterr().err
@@ -434,6 +434,26 @@ class TestSearchScoring:
434
434
  with pytest.raises(EmbeddingCacheMismatch):
435
435
  s.search("anything", limit=3)
436
436
 
437
+ def test_kept_nodes_are_the_pool_others_never_enter(self, fake_model, graph_writer):
438
+ """A filter applies before the pool is cut, so a kept node far down
439
+ the ranking is still found."""
440
+ s = SemanticSearcher(str(graph_writer()))
441
+ dim = s.embeddings.shape[1]
442
+ s.embeddings = np.zeros((len(s.nodes), dim), dtype="float32")
443
+ s.embeddings[:, 0] = 1.0
444
+ last = len(s.nodes) - 1
445
+ s.embeddings[last] = 0.0
446
+ s.embeddings[last, 1] = 1.0
447
+ q = np.zeros(dim, dtype="float32")
448
+ q[0], q[1] = 1.0, 0.1
449
+ s.model = FakeSentenceTransformer(dim=dim, prompts={})
450
+ s.model.encode = lambda texts, **kw: q.reshape(1, -1)
451
+ wanted = s.nodes[last]["id"]
452
+
453
+ hits = s.search("q", limit=1, min_score=-1.0, keep={wanted})
454
+ assert [n["id"] for n, _ in hits] == [wanted]
455
+ assert s.search("q", limit=1, min_score=-1.0, keep=set()) == []
456
+
437
457
  def test_results_are_sorted_and_limited(self, fake_model, graph_writer):
438
458
  s = SemanticSearcher(str(graph_writer()))
439
459
  hits = s.search("alpha", limit=2, min_score=-1.0)
@@ -1709,3 +1709,41 @@ async def test_a_context_budget_below_one_is_refused(tmp_path, bound):
1709
1709
  result = await DescryService(config).context("FILE:x.py::f", depth=2, **{bound: 0})
1710
1710
  assert isinstance(result, ServiceError)
1711
1711
  assert f"{bound} must be at least 1, got 0." in result
1712
+
1713
+
1714
+ @pytest.mark.asyncio
1715
+ @pytest.mark.parametrize(
1716
+ ("terms", "lang", "found"),
1717
+ [
1718
+ (["who", "talks", "to", "nothing"], None, ["FILE:x.py::f", "FILE:y.rs::g"]),
1719
+ (["who", "talks", "to", "nothing"], "python", ["FILE:x.py::f"]),
1720
+ # The keywords find `f` too; the semantic side is still filtered
1721
+ (["f"], "python", ["FILE:x.py::f"]),
1722
+ ],
1723
+ )
1724
+ async def test_search_keeps_semantic_matches_no_keyword_finds(
1725
+ tmp_path, terms, lang, found
1726
+ ):
1727
+ """The semantic side runs because the keywords found little; what it
1728
+ finds is answered, under the same filters as the keywords."""
1729
+ config = _plain_config(tmp_path)
1730
+ py = {"id": "FILE:x.py::f", "type": "Function", "metadata": {"name": "f"}}
1731
+ rs = {"id": "FILE:y.rs::g", "type": "Function", "metadata": {"name": "g"}}
1732
+ _write_graph(config, nodes=[py, rs])
1733
+ svc = DescryService(config)
1734
+
1735
+ class Searcher:
1736
+ def __init__(self, graph_path, **kwargs):
1737
+ pass
1738
+
1739
+ def ensure_model(self):
1740
+ pass
1741
+
1742
+ def search(self, query, limit=10, min_score=0.0, keep=None):
1743
+ return [
1744
+ r for r in [(py, 0.9), (rs, 0.8)] if keep is None or r[0]["id"] in keep
1745
+ ]
1746
+
1747
+ svc._semantic_available, svc._SemanticSearcher = True, Searcher
1748
+ data = await svc.search_data(terms, lang=lang)
1749
+ assert ([n["id"] for n in data["results"]], data["method"]) == (found, "hybrid")
@@ -301,12 +301,16 @@ class TestFindCallPath:
301
301
 
302
302
  os.unlink(f.name)
303
303
 
304
+ @pytest.mark.parametrize(
305
+ "target", ["validate_token", "FILE:auth.rs::validate_token"]
306
+ )
304
307
  def test_structured_flow_stops_at_the_target_and_reports_the_path(
305
- self, call_path_graph
308
+ self, call_path_graph, target
306
309
  ):
310
+ """The target is named by name or by node ID."""
307
311
  q = GraphQuerier(call_path_graph)
308
312
 
309
- tree = q.trace_flow_structured("handle_request", target="validate_token")
313
+ tree = q.trace_flow_structured("handle_request", target=target)
310
314
 
311
315
  assert tree["path_to_target"] == [
312
316
  "handle_request",
@@ -1073,6 +1077,14 @@ class TestLanguageFilter:
1073
1077
  def test_all_and_none_do_not_filter(self, querier, lang):
1074
1078
  assert len(querier.search_docs(["handler"], lang=lang)) == len(self.FILES)
1075
1079
 
1080
+ @pytest.mark.parametrize(
1081
+ ("kind", "found"), [("function", len(FILES)), ("class", 0), ("struct", 0)]
1082
+ )
1083
+ def test_type_keeps_only_that_type(self, querier, kind, found):
1084
+ """A type the graph has none of, or no type descry has, matches
1085
+ nothing, as an unknown language does."""
1086
+ assert len(querier.search_docs(["handler"], symbol_type=kind)) == found
1087
+
1076
1088
 
1077
1089
  class TestCallPathDepthAndOutcome:
1078
1090
  def _chain(self, tmp_path, length, **config):