descry-codegraph 0.3.1__tar.gz → 0.3.2__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 (135) hide show
  1. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/CHANGELOG.md +93 -0
  2. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/CLAUDE.md +1 -1
  3. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/PKG-INFO +1 -1
  4. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/pyproject.toml +1 -1
  5. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/__init__.py +1 -1
  6. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/_graph.py +14 -0
  7. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/cli.py +6 -1
  8. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/embeddings.py +10 -5
  9. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/generate.py +2 -11
  10. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/git_history.py +40 -33
  11. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/handlers.py +81 -34
  12. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/mcp_server.py +1 -1
  13. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/query.py +562 -718
  14. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/web/server.py +26 -9
  15. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/web/web/index.html +32 -8
  16. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_cli.py +1 -1
  17. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_embeddings.py +17 -0
  18. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_git_history.py +16 -2
  19. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_handlers.py +116 -5
  20. descry_codegraph-0.3.2/tests/test_interfaces.py +296 -0
  21. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_mcp.py +1 -1
  22. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_query.py +638 -63
  23. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_web_server.py +59 -6
  24. descry_codegraph-0.3.1/tests/test_interfaces.py +0 -178
  25. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/.github/assets/01-landing.png +0 -0
  26. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/.github/workflows/ci.yml +0 -0
  27. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/.github/workflows/release.yml +0 -0
  28. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/.gitignore +0 -0
  29. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/LICENSE +0 -0
  30. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/README.md +0 -0
  31. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/SECURITY.md +0 -0
  32. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/THIRD-PARTY-NOTICES.md +0 -0
  33. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/claude-skill/README.md +0 -0
  34. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/claude-skill/descry/SKILL.md +0 -0
  35. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/claude-skill/descry/references/cli-reference.md +0 -0
  36. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/claude-skill/descry/references/mcp-tools.md +0 -0
  37. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/claude-skill/descry/references/recipes.md +0 -0
  38. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/justfile +0 -0
  39. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/pi-extension/README.md +0 -0
  40. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/pi-extension/descry-cli.py +0 -0
  41. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/pi-extension/descry-tools.ts +0 -0
  42. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/_comments.py +0 -0
  43. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/_env.py +0 -0
  44. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/_files.py +0 -0
  45. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/_stdlib.py +0 -0
  46. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/clang_parser.py +0 -0
  47. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/cross_lang.py +0 -0
  48. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/dart_parser.py +0 -0
  49. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/dotnet_parser.py +0 -0
  50. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/go_parser.py +0 -0
  51. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/java_parser.py +0 -0
  52. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/php_parser.py +0 -0
  53. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/ruby_parser.py +0 -0
  54. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/__init__.py +0 -0
  55. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapter.py +0 -0
  56. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/__init__.py +0 -0
  57. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/_empty_environment.json +0 -0
  58. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/_java_compat.init.gradle +0 -0
  59. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/clang.py +0 -0
  60. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/dart.py +0 -0
  61. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/dotnet.py +0 -0
  62. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/go.py +0 -0
  63. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/java.py +0 -0
  64. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/php.py +0 -0
  65. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/python.py +0 -0
  66. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/ruby.py +0 -0
  67. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/rust.py +0 -0
  68. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/adapters/typescript.py +0 -0
  69. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/cache.py +0 -0
  70. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/parser.py +0 -0
  71. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/pb2.py +0 -0
  72. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/scip/support.py +0 -0
  73. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/c.json +0 -0
  74. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/cpp.json +0 -0
  75. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/dart.json +0 -0
  76. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/dotnet.json +0 -0
  77. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/go.json +0 -0
  78. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/java.json +0 -0
  79. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/node.json +0 -0
  80. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/php.json +0 -0
  81. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/python.json +0 -0
  82. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/ruby.json +0 -0
  83. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/rust.json +0 -0
  84. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/stdlib/typescript.json +0 -0
  85. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/web/__init__.py +0 -0
  86. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/src/descry/web/web/style.css +0 -0
  87. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/__init__.py +0 -0
  88. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/conftest.py +0 -0
  89. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/README.md +0 -0
  90. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/__init__.py +0 -0
  91. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/compare.py +0 -0
  92. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/corpus.py +0 -0
  93. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/metrics.py +0 -0
  94. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/registry.py +0 -0
  95. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/results/results-descry.json +0 -0
  96. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/eval/results/results-ripgrep.json +0 -0
  97. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/fixtures/small_graph.json +0 -0
  98. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_backtracking.py +0 -0
  99. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_clang_parser.py +0 -0
  100. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_config.py +0 -0
  101. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_cross_lang.py +0 -0
  102. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_dart_parser.py +0 -0
  103. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_dotnet_parser.py +0 -0
  104. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_generate.py +0 -0
  105. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_go_parser.py +0 -0
  106. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_hardening.py +0 -0
  107. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_java_parser.py +0 -0
  108. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_non_regular_files.py +0 -0
  109. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_php_parser.py +0 -0
  110. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_ruby_parser.py +0 -0
  111. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip.py +0 -0
  112. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_cache.py +0 -0
  113. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_clang.py +0 -0
  114. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_dart.py +0 -0
  115. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_dotnet.py +0 -0
  116. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_go.py +0 -0
  117. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_java.py +0 -0
  118. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_php.py +0 -0
  119. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_scip_ruby.py +0 -0
  120. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_stdlib.py +0 -0
  121. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tests/test_written_through.py +0 -0
  122. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/README.md +0 -0
  123. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/common.py +0 -0
  124. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_c.py +0 -0
  125. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_cpp.py +0 -0
  126. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_dart.py +0 -0
  127. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_dotnet.py +0 -0
  128. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_go.py +0 -0
  129. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_java.py +0 -0
  130. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_node.py +0 -0
  131. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_php.py +0 -0
  132. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_python.py +0 -0
  133. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_ruby.py +0 -0
  134. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_rust.py +0 -0
  135. {descry_codegraph-0.3.1 → descry_codegraph-0.3.2}/tools/stdlib/gen_typescript.py +0 -0
@@ -6,6 +6,99 @@ 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.2] — 2026-10-23
10
+
11
+ ### Fixed
12
+
13
+ - A caller that reaches a symbol only as one of an unresolved call's
14
+ candidates is counted apart from its callers. `in_degree` ("N callers")
15
+ counts the callers whose calls are resolved to the symbol, and is
16
+ counted again as a graph loads, so a graph from 0.3.1 needs no reindex.
17
+ Every other count gives the others as "(+N possible)": brief `context`
18
+ (callers and callees), `callers`, `callees`, and the web page's headers
19
+ (`possible` in the web API). Lists mark each one; `changes` does so in
20
+ text, in its structured form (`candidates`, `callers_possible`) and on
21
+ the page.
22
+ - A function no longer counts as a certain caller of a symbol it defines
23
+ when its call to it is unresolved.
24
+ - A node ID names that node alone: `callers`, `callees`, `quick`, `flow`
25
+ and `path` given one no longer also match other definitions filed under
26
+ the same owner and name (a Ruby `self.x` beside `x`).
27
+ - `context`'s usage examples are calls of that symbol, those resolved to
28
+ it first, then those it is one of the candidates of, marked; they were
29
+ any unresolved call ending in its name.
30
+ - `callers` of a name lists the calls the resolver left unresolved with no
31
+ candidates apart from its callers, marked "(unresolved; same name)" and
32
+ counted as "+N unresolved" (`unresolved` in the web API). Some are calls
33
+ of the project's definition the resolver cannot follow (a Rust re-export,
34
+ a TypeScript static member), some are calls of something else with that
35
+ name (`loader.list_templates()` on a library's loader). Neither is
36
+ counted as a caller. An unresolved call with candidates reaches only
37
+ those. Callers asked for by node ID are those resolved to it or narrowed
38
+ to it.
39
+ - A list cut short (`callers`, `callees`, `context`'s callers, `changes`,
40
+ backward `flow` in text and as a tree) keeps the callers resolved to the
41
+ symbol first, and says how many it leaves out and how many of those only
42
+ may be; a `flow` tree node gives them as `more` and `more_possible`, and
43
+ the web API's `callers` and `callees` as `more`, shown on the page.
44
+ - `flow`, in text and as a tree, places each symbol at the first level
45
+ that reaches it and marks it "(at level N)" where it is met again
46
+ (`seen`, that level, in the tree); a direct callee or caller reached
47
+ first through another one was dropped from its level, with everything
48
+ below it. `context`, at every depth, lists all of the symbol's own
49
+ callees and cuts each level below to the per-level limit with a note; it
50
+ opens a callee (inlines it and lists its own callees) at the first level
51
+ that reaches it while the token budget covers it, lists every other
52
+ callee by its signature or "(at level N)", and says when the budget, the
53
+ node limit or the timeout cut it short. Depth 1 ignored `max_tokens`. A
54
+ `flow` that stops early says so: "(timeout)" or "(node limit)" in text,
55
+ `cut` in the tree, a note on the page.
56
+ - A `flow` of `depth` N shows N levels of calls below the start; `depth=1`
57
+ showed the start alone. `[query] max_depth` is that many levels too, for
58
+ `flow` and for `context`'s callee expansion. A depth past it is traced to
59
+ it and says so; the web API returns the depth traced, and `/api/health`
60
+ gives the ceiling the page's depth field is held to.
61
+ - A callee the resolver left unresolved is no longer shown or counted as
62
+ certain or as external. `context`, at every depth, names it as one of
63
+ its candidates, or "(unresolved)" where it found none. `callees` marks
64
+ it the same way and counts it apart ("+N unresolved"). A callee is what
65
+ a call is written as: calls written apart (`a.add`, `b.add`) are two
66
+ callees, and calls written alike may reach any definition one of them
67
+ is narrowed to. Forward `flow` follows a possible callee to each
68
+ definition it may reach, marked, and `context` at a depth past 1 says
69
+ how many callees each level leaves out.
70
+ - `callees` says how many callees its `limit` leaves out, and of a name
71
+ several definitions share, which one it answers for (`others` in the web
72
+ API).
73
+ - A `limit`, a `flow` or `context` `depth`, a `context` `max_tokens` or
74
+ `callee_budget`, or a `path` `max_depth` below 1 is refused by every
75
+ tool; `limit=0` answered as if there were no
76
+ callers, matches, changes or history.
77
+ - The web page's changes view no longer throws for a changed symbol with
78
+ no callers.
79
+ - `context --max-output-tokens` cuts the parts the answer is made of, not
80
+ its text: expanded callees, then usage examples past 2, related tests
81
+ and configuration, callers past 5 (still counting possible callers
82
+ apart), the symbol's callees past 5 (planned again for those five, so
83
+ the note counts by kind only callees drawn nowhere in the answer and no
84
+ "(at level N)" mark points at a callee cut away), and the source past 30
85
+ of the symbol's lines, saying what each step left out; a section dropped
86
+ whole keeps its heading with "(omitted, max_output_tokens limit)".
87
+ It read the rendered text back, so code with a heading-like or a fence
88
+ line in it was cut wrongly or not at all, opened callees kept their
89
+ headings without their code, and counts were wrong.
90
+ - `flow` text indents the code it inlines as deep as its fence. The code
91
+ was printed from the first column inside an indented fence, so it broke
92
+ the tree, and below the first level the fence read as an indented code
93
+ block.
94
+ - A command that loads no embedding model no longer imports
95
+ sentence-transformers, and with it torch, as the service starts: where
96
+ they are installed every CLI command spent seconds on it.
97
+ - `churn` ranks symbols, and co-change pairs of symbols, with the same
98
+ number of commits by node ID, so the same history gives the same list.
99
+ Their order among ties changed from run to run, and so, at the limit,
100
+ did which of them were listed.
101
+
9
102
  ## [0.3.1] — 2026-10-22
10
103
 
11
104
  ### 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 (2755 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 (2810 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.1
3
+ Version: 0.3.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "descry-codegraph"
3
- version = "0.3.1"
3
+ version = "0.3.2"
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.1"
3
+ __version__ = "0.3.2"
4
4
  __all__ = ["__version__"]
@@ -112,4 +112,18 @@ def load_graph_with_schema(path: Path | str) -> dict:
112
112
  f"run 'descry index' to rebuild."
113
113
  )
114
114
  _check_shape(data)
115
+ count_callers(data["nodes"], data["edges"])
115
116
  return data
117
+
118
+
119
+ def count_callers(nodes: list, edges: list) -> None:
120
+ """Set each node's `in_degree` to the number of its callers: the
121
+ symbols whose calls are resolved to it, not those an unresolved call
122
+ only may be. A graph is counted again as it loads, so one written by an
123
+ older descry counts them the same way."""
124
+ callers: dict[str, set] = {}
125
+ for edge in edges:
126
+ if edge["relation"] in CALL_RELATIONS:
127
+ callers.setdefault(edge["target"], set()).add(edge["source"])
128
+ for node in nodes:
129
+ node["metadata"]["in_degree"] = len(callers.get(node["id"], ()))
@@ -302,7 +302,12 @@ def main():
302
302
  choices=["forward", "backward"],
303
303
  help="Trace direction (default: forward)",
304
304
  )
305
- sub.add_argument("--depth", type=int, default=3, help="Trace depth (default: 3)")
305
+ sub.add_argument(
306
+ "--depth",
307
+ type=int,
308
+ default=3,
309
+ help="Levels of calls below the start (default: 3)",
310
+ )
306
311
  sub.add_argument("--target", default=None, help="Target symbol to reach")
307
312
  sub.add_argument(
308
313
  "--inline-threshold",
@@ -12,6 +12,7 @@ Usage:
12
12
 
13
13
  import contextlib
14
14
  import hashlib
15
+ import importlib.util
15
16
  import json
16
17
  import logging
17
18
  import os
@@ -347,13 +348,14 @@ def _resolve_cache_dir(graph_path: Path, cache_dir: str | None = None) -> Path:
347
348
  # Try to import embedding dependencies
348
349
  try:
349
350
  import numpy as np
350
- from sentence_transformers import SentenceTransformer
351
-
352
- EMBEDDINGS_AVAILABLE = True
353
351
  except ImportError:
354
- EMBEDDINGS_AVAILABLE = False
355
352
  np = None
356
- SentenceTransformer = None
353
+ # Imported by _load_sentence_transformer: it brings in torch, which takes
354
+ # seconds a command that loads no model should not spend.
355
+ SentenceTransformer = None
356
+ EMBEDDINGS_AVAILABLE = (
357
+ np is not None and importlib.util.find_spec("sentence_transformers") is not None
358
+ )
357
359
 
358
360
 
359
361
  def embeddings_available() -> bool:
@@ -376,6 +378,9 @@ def _load_sentence_transformer(model_name: str, revision: str | None = None):
376
378
  model_name: Registry alias, HuggingFace repo id, or local path.
377
379
  revision: Overrides the registry's pinned revision.
378
380
  """
381
+ global SentenceTransformer
382
+ if SentenceTransformer is None:
383
+ from sentence_transformers import SentenceTransformer
379
384
  spec = resolve_model_spec(model_name)
380
385
  target = spec.repo_id if spec else model_name
381
386
  if revision is None and spec:
@@ -25433,19 +25433,10 @@ class CodeGraphBuilder:
25433
25433
  output_path: Path to write the graph JSON
25434
25434
  scip_index: Optional ScipIndex for type-aware resolution
25435
25435
  """
25436
- from descry._graph import CALL_RELATIONS, CURRENT_SCHEMA
25436
+ from descry._graph import CURRENT_SCHEMA, count_callers
25437
25437
 
25438
25438
  self.resolve_references(scip_index=scip_index)
25439
- # The number of its callers: those certain and those it is one of
25440
- # the candidates of
25441
- callers = {}
25442
- for edge in self.edges:
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"])
25447
- for node in self.nodes:
25448
- node["metadata"]["in_degree"] = len(callers.get(node["id"], ()))
25439
+ count_callers(self.nodes, self.edges)
25449
25440
  # Atomic write: stream to a sibling tmp file then os.replace. A
25450
25441
  # concurrent reader (MCP prewarm, web `/api/health`, another CLI
25451
25442
  # invocation) that stats the destination mid-write would otherwise
@@ -758,7 +758,7 @@ class GitHistoryAnalyzer:
758
758
 
759
759
  # Sort by commit count
760
760
  sorted_symbols = sorted(
761
- symbol_commits.items(), key=lambda x: len(x[1]), reverse=True
761
+ symbol_commits.items(), key=lambda x: (-len(x[1]), x[0])
762
762
  )[:limit]
763
763
 
764
764
  lines = []
@@ -923,7 +923,7 @@ class GitHistoryAnalyzer:
923
923
 
924
924
  # SYMBOLS MODE
925
925
  sorted_symbols = sorted(
926
- symbol_commits.items(), key=lambda x: len(x[1]), reverse=True
926
+ symbol_commits.items(), key=lambda x: (-len(x[1]), x[0])
927
927
  )[:limit]
928
928
 
929
929
  symbols = []
@@ -983,7 +983,7 @@ class GitHistoryAnalyzer:
983
983
  frequent_pairs = [
984
984
  (pair, count) for pair, count in pair_counts.items() if count >= 2
985
985
  ]
986
- frequent_pairs.sort(key=lambda x: x[1], reverse=True)
986
+ frequent_pairs.sort(key=lambda x: (-x[1], x[0]))
987
987
 
988
988
  for (sym_a, sym_b), count in frequent_pairs[:limit]:
989
989
  _, name_a, loc_a = self._get_node_display(sym_a)
@@ -1081,7 +1081,7 @@ class GitHistoryAnalyzer:
1081
1081
  frequent_pairs = [
1082
1082
  (pair, count) for pair, count in pair_counts.items() if count >= 2
1083
1083
  ]
1084
- frequent_pairs.sort(key=lambda x: x[1], reverse=True)
1084
+ frequent_pairs.sort(key=lambda x: (-x[1], x[0]))
1085
1085
  frequent_pairs = frequent_pairs[:limit]
1086
1086
 
1087
1087
  if frequent_pairs:
@@ -1660,26 +1660,43 @@ class GitHistoryAnalyzer:
1660
1660
  f"(+{stats['added']}/-{stats['removed']})"
1661
1661
  )
1662
1662
 
1663
- # Show callers if requested
1664
1663
  if show_callers and self.querier:
1665
- # The callers of this symbol, not of every one of its name
1666
- callers = self.querier.get_callers(node_id)
1664
+ callers, total, possible = self._symbol_callers(node_id)
1667
1665
  if callers:
1668
- caller_names = []
1669
- for caller_id in callers[:5]:
1670
- c_node = self.querier.get_node_info(caller_id)
1671
- if c_node:
1672
- caller_names.append(c_node["metadata"].get("name", "?"))
1673
- else:
1674
- # Extract name from node ID
1675
- parts = caller_id.split("::")
1676
- caller_names.append(parts[-1] if parts else "?")
1677
-
1678
- more = f", +{len(callers) - 5} more" if len(callers) > 5 else ""
1679
- lines.append(f" Callers: {', '.join(caller_names)}{more}")
1666
+ names = ", ".join(
1667
+ c["name"] + self.querier.one_of(c["candidates"])
1668
+ for c in callers
1669
+ )
1670
+ hidden = possible - sum(bool(c["candidates"]) for c in callers)
1671
+ hidden_certain = total - len(callers) - hidden
1672
+ more = (
1673
+ f"; {self.querier.more_callers(hidden_certain, hidden)}"
1674
+ if total > len(callers)
1675
+ else ""
1676
+ )
1677
+ lines.append(f" Callers: {names}{more}")
1680
1678
 
1681
1679
  return "\n".join(lines)
1682
1680
 
1681
+ def _symbol_callers(self, node_id):
1682
+ """The first five callers of the symbol ``node_id``, certain ones
1683
+ first, each ``{"id", "name", "candidates"}`` where ``candidates`` is
1684
+ the number an unresolved call reaching it only may be one of; the
1685
+ number of its callers; and how many of those only may be."""
1686
+ caller_ids, ambiguous = self.querier.ordered_callers(node_id)
1687
+ callers = []
1688
+ for caller_id in caller_ids[:5]:
1689
+ c_node = self.querier.get_node_info(caller_id)
1690
+ name = (
1691
+ c_node["metadata"].get("name", "?")
1692
+ if c_node
1693
+ else caller_id.split("::")[-1]
1694
+ )
1695
+ callers.append(
1696
+ {"id": caller_id, "name": name, "candidates": ambiguous.get(caller_id)}
1697
+ )
1698
+ return callers, len(caller_ids), len(ambiguous)
1699
+
1683
1700
  def get_changes_structured(
1684
1701
  self,
1685
1702
  commit_range: str | None = None,
@@ -1742,21 +1759,11 @@ class GitHistoryAnalyzer:
1742
1759
  }
1743
1760
 
1744
1761
  if show_callers and self.querier:
1745
- caller_ids = self.querier.get_callers(node_id)
1746
- if caller_ids:
1747
- callers = []
1748
- for caller_id in caller_ids[:5]:
1749
- c_node = self.querier.get_node_info(caller_id)
1750
- c_name = (
1751
- c_node["metadata"].get("name", "?")
1752
- if c_node
1753
- else caller_id.split("::")[-1]
1754
- if "::" in caller_id
1755
- else "?"
1756
- )
1757
- callers.append({"id": caller_id, "name": c_name})
1762
+ callers, total, possible = self._symbol_callers(node_id)
1763
+ if callers:
1758
1764
  entry["callers"] = callers
1759
- entry["callers_total"] = len(caller_ids)
1765
+ entry["callers_total"] = total
1766
+ entry["callers_possible"] = possible
1760
1767
 
1761
1768
  symbols.append(entry)
1762
1769
 
@@ -946,10 +946,12 @@ _GRAPH_NOT_FOUND = "Graph not found. Run descry ensure first."
946
946
  _INDEX_BUSY = "An index run is already in progress"
947
947
 
948
948
 
949
- def _limit_error(limit: int) -> str | None:
950
- """Why `limit` cannot be used, or None. A negative one slices from the end."""
951
- if limit < 0:
952
- return f"limit must not be negative, got {limit}."
949
+ def limit_error(limit: int, name: str = "limit") -> str | None:
950
+ """Why the limit or depth ``name`` cannot be ``limit``, or None: a
951
+ negative one slices from the end, and none at all answers as if there
952
+ were nothing to show."""
953
+ if limit < 1:
954
+ return f"{name} must be at least 1, got {limit}."
953
955
  return None
954
956
 
955
957
 
@@ -1712,7 +1714,7 @@ class DescryService:
1712
1714
 
1713
1715
  async def callers_data(self, name: str, limit: int = 20) -> dict:
1714
1716
  """Callers of a symbol, as data. An exact miss retries with fuzzy=True."""
1715
- if error := _limit_error(limit):
1717
+ if error := limit_error(limit):
1716
1718
  return {"ok": False, "error": error, "reason": "invalid"}
1717
1719
  q = await self._get_querier()
1718
1720
  if not q:
@@ -1721,15 +1723,11 @@ class DescryService:
1721
1723
 
1722
1724
  @staticmethod
1723
1725
  def _callers_from(q, name: str, limit: int) -> dict:
1724
- all_callers = q.get_callers(name)
1725
- ambiguous = q.get_ambiguous_callers(name)
1726
+ all_callers, ambiguous = q.ordered_callers(name)
1726
1727
  fuzzy = False
1727
1728
  if not all_callers:
1728
- all_callers = q.get_callers(name, fuzzy=True)
1729
- ambiguous = q.get_ambiguous_callers(name, fuzzy=True)
1729
+ all_callers, ambiguous = q.ordered_callers(name, fuzzy=True)
1730
1730
  fuzzy = bool(all_callers)
1731
- # Certain callers come before those that are one of several candidates.
1732
- all_callers = sorted(all_callers, key=ambiguous.__contains__)
1733
1731
 
1734
1732
  return {
1735
1733
  "ok": True,
@@ -1738,12 +1736,13 @@ class DescryService:
1738
1736
  "fuzzy": fuzzy,
1739
1737
  "total": len(all_callers),
1740
1738
  "callers": all_callers[:limit],
1741
- "ambiguous": {c: ambiguous[c] for c in all_callers if c in ambiguous},
1739
+ "left_out": all_callers[limit:],
1740
+ "ambiguous": ambiguous,
1742
1741
  }
1743
1742
 
1744
1743
  async def callees_data(self, name: str, limit: int = 20) -> dict:
1745
1744
  """Callees of a symbol, as data. Same fuzzy fallback as callers_data."""
1746
- if error := _limit_error(limit):
1745
+ if error := limit_error(limit):
1747
1746
  return {"ok": False, "error": error, "reason": "invalid"}
1748
1747
  q = await self._get_querier()
1749
1748
  if not q:
@@ -1784,12 +1783,15 @@ class DescryService:
1784
1783
  "node": node,
1785
1784
  "total": len(callees),
1786
1785
  "callees": callees[:limit],
1787
- "ambiguous": {c: ambiguous[c] for c in callees if c in ambiguous},
1786
+ "left_out": callees[limit:],
1787
+ "ambiguous": ambiguous,
1788
+ # The definitions of the name besides the one answered for
1789
+ "others": len(func_matches) - 1,
1788
1790
  }
1789
1791
 
1790
1792
  async def callers(self, name: str, limit: int = 20) -> str:
1791
1793
  """Find all callers of a symbol."""
1792
- if error := _limit_error(limit):
1794
+ if error := limit_error(limit):
1793
1795
  return ServiceError(f"ERROR: {error}")
1794
1796
  # One querier for lookup and rendering: a second fetch returns None
1795
1797
  # if the graph file has been removed in between.
@@ -1808,7 +1810,11 @@ class DescryService:
1808
1810
  f"No callers of '{name}'. Try descry search to verify symbol exists."
1809
1811
  )
1810
1812
  else:
1811
- lines = [f"{len(callers)} caller(s) of '{name}'{fuzzy_note}:"]
1813
+ certain, possible, unresolved = q.kinds(
1814
+ callers + data["left_out"], ambiguous
1815
+ )
1816
+ extra = q.possible(possible, unresolved)
1817
+ lines = [f"{certain} caller(s) of '{name}'{fuzzy_note}{extra}:"]
1812
1818
  for caller in callers:
1813
1819
  node_info = q.get_node_info(caller)
1814
1820
  one_of = q.one_of(ambiguous.get(caller))
@@ -1822,16 +1828,16 @@ class DescryService:
1822
1828
  else:
1823
1829
  lines.append(f" {caller}{one_of}")
1824
1830
  if len(callers) < total_count:
1825
- lines.append(
1826
- f" ... ({total_count - len(callers)} more, limit {limit})"
1827
- )
1831
+ certain, possible, unresolved = q.kinds(data["left_out"], ambiguous)
1832
+ more = q.more_callers(certain, possible, unresolved=unresolved)
1833
+ lines.append(f" ... ({more}, limit {limit})")
1828
1834
  result = "\n".join(lines)
1829
1835
 
1830
1836
  return await self._format_response(result)
1831
1837
 
1832
1838
  async def callees(self, name: str, limit: int = 20) -> str:
1833
1839
  """Find what a symbol calls."""
1834
- if error := _limit_error(limit):
1840
+ if error := limit_error(limit):
1835
1841
  return ServiceError(f"ERROR: {error}")
1836
1842
  q = await self._get_querier()
1837
1843
  if not q:
@@ -1842,15 +1848,32 @@ class DescryService:
1842
1848
 
1843
1849
  node = data["node"]
1844
1850
  callees = data["callees"]
1851
+ total = data["total"]
1845
1852
  ambiguous = data.get("ambiguous", {})
1846
1853
  fuzzy_note = " (fuzzy match)" if data["fuzzy"] else ""
1847
- display_name = node.get("metadata", {}).get("name", name)
1848
- if not callees:
1849
- result = f"'{display_name}'{fuzzy_note} calls no tracked functions."
1854
+ # A name several definitions have is answered for one, named by ID
1855
+ display_name = (
1856
+ node["id"] if data["others"] else node.get("metadata", {}).get("name", name)
1857
+ )
1858
+ others = (
1859
+ f"\n({data['others']} other "
1860
+ f"{'match(es) for' if data['fuzzy'] else 'definition(s) of'} '{name}'; "
1861
+ "use a node ID to choose)"
1862
+ if data["others"]
1863
+ else ""
1864
+ )
1865
+ if not total:
1866
+ result = f"'{display_name}'{fuzzy_note} calls no tracked functions.{others}"
1850
1867
  else:
1851
- lines = [f"'{display_name}'{fuzzy_note} calls {len(callees)} function(s):"]
1868
+ certain, possible, unresolved = q.kinds(
1869
+ callees + data["left_out"], ambiguous
1870
+ )
1871
+ possible = q.possible(possible, unresolved)
1872
+ lines = [
1873
+ f"'{display_name}'{fuzzy_note} calls {certain} function(s){possible}:"
1874
+ ]
1852
1875
  for callee in callees:
1853
- one_of = q.one_of(ambiguous.get(callee))
1876
+ one_of = q.callee_note(ambiguous.get(callee))
1854
1877
  callee_info = q.get_node_info(callee)
1855
1878
  if callee_info:
1856
1879
  lineno = callee_info.get("metadata", {}).get("lineno")
@@ -1861,7 +1884,11 @@ class DescryService:
1861
1884
  lines.append(f" {callee}{one_of}")
1862
1885
  else:
1863
1886
  lines.append(f" {callee}{one_of}")
1864
- result = "\n".join(lines)
1887
+ if len(callees) < total:
1888
+ certain, possible, unresolved = q.kinds(data["left_out"], ambiguous)
1889
+ more = q.more_callers(certain, possible, "callees", unresolved)
1890
+ lines.append(f" ... ({more}, limit {limit})")
1891
+ result = "\n".join(lines) + others
1865
1892
 
1866
1893
  return await self._format_response(result)
1867
1894
 
@@ -1879,6 +1906,13 @@ class DescryService:
1879
1906
  max_output_tokens: int | None = None,
1880
1907
  ) -> str:
1881
1908
  """Get full context for a symbol."""
1909
+ for value, name in (
1910
+ (depth, "depth"),
1911
+ (max_tokens, "max_tokens"),
1912
+ (callee_budget, "callee_budget"),
1913
+ ):
1914
+ if error := limit_error(value, name):
1915
+ return ServiceError(f"ERROR: {error}")
1882
1916
  q = await self._get_querier()
1883
1917
  if not q:
1884
1918
  return ServiceError(f"ERROR: {self._no_graph_error()}")
@@ -1928,7 +1962,10 @@ class DescryService:
1928
1962
  result, include_header=False, max_lines=50
1929
1963
  )
1930
1964
 
1931
- depth_note = f" [depth={depth}]" if depth > 1 else ""
1965
+ traced = q.flow_depth(depth)
1966
+ depth_note = f" [depth={traced}]" if traced > 1 else ""
1967
+ if traced < depth:
1968
+ depth_note += f" [depth {depth} is past [query] max_depth]"
1932
1969
  full_note = " [full=true]" if full else ""
1933
1970
  head_note = f" [head_lines={head_lines}]" if head_lines else ""
1934
1971
  expand_note = " [expand_callees=true]" if expand_callees else ""
@@ -1951,8 +1988,11 @@ class DescryService:
1951
1988
  """Call flow from a starting symbol, as data.
1952
1989
 
1953
1990
  `result` is the markdown trace, or the tree of
1954
- `GraphQuerier.trace_flow_structured` when `structured`.
1991
+ `GraphQuerier.trace_flow_structured` when `structured`; `depth` is
1992
+ the depth traced, at most the project's ceiling.
1955
1993
  """
1994
+ if error := limit_error(depth, "depth"):
1995
+ return {"ok": False, "error": error, "reason": "invalid"}
1956
1996
  q = await self._get_querier()
1957
1997
  if not q:
1958
1998
  return {"ok": False, "error": self._no_graph_error()}
@@ -1965,7 +2005,12 @@ class DescryService:
1965
2005
  target=target,
1966
2006
  inline_threshold=inline_threshold,
1967
2007
  )
1968
- return {"ok": True, "error": None, "result": result}
2008
+ return {
2009
+ "ok": True,
2010
+ "error": None,
2011
+ "result": result,
2012
+ "depth": q.flow_depth(depth),
2013
+ }
1969
2014
 
1970
2015
  async def flow(
1971
2016
  self,
@@ -1998,7 +2043,7 @@ class DescryService:
1998
2043
  Returns `{"ok", "error", "results", "method"}`; `method` is "keyword"
1999
2044
  or "hybrid".
2000
2045
  """
2001
- if error := _limit_error(limit):
2046
+ if error := limit_error(limit):
2002
2047
  return {
2003
2048
  "ok": False,
2004
2049
  "error": error,
@@ -2334,8 +2379,8 @@ class DescryService:
2334
2379
  reported as a missing graph; `reason` classifies it as "invalid",
2335
2380
  "unavailable", "no_graph", "loading" or "failed".
2336
2381
  """
2337
- if limit_error := _limit_error(limit):
2338
- reason, error = "invalid", limit_error
2382
+ if refused := limit_error(limit):
2383
+ reason, error = "invalid", refused
2339
2384
  elif not self._semantic_available or not self._SemanticSearcher:
2340
2385
  reason, error = "unavailable", "Semantic search not available."
2341
2386
  elif not self.config.graph_path.exists():
@@ -2644,6 +2689,8 @@ class DescryService:
2644
2689
  `status` and `path` are those of `GraphQuerier.find_call_path_result`.
2645
2690
  Unless `status` is "found", `message` says why there is no path.
2646
2691
  """
2692
+ if error := limit_error(max_depth, "max_depth"):
2693
+ return {"ok": False, "error": error, "reason": "invalid"}
2647
2694
  q = await self._get_querier()
2648
2695
  if not q:
2649
2696
  return {"ok": False, "error": self._no_graph_error()}
@@ -2925,10 +2972,10 @@ class DescryService:
2925
2972
  """Run a GitHistoryAnalyzer method off the event loop, as data.
2926
2973
 
2927
2974
  `result` is what the method returned. On failure `reason` is
2928
- "invalid" for a negative `limit`, "unavailable" when the git history
2975
+ "invalid" for a `limit` below 1, "unavailable" when the git history
2929
2976
  module is missing and "failed" when the analysis raised.
2930
2977
  """
2931
- if error := _limit_error(kwargs["limit"]):
2978
+ if error := limit_error(kwargs["limit"]):
2932
2979
  return {"ok": False, "error": error, "reason": "invalid"}
2933
2980
  if not self._git_available:
2934
2981
  return {
@@ -243,7 +243,7 @@ async def descry_flow(
243
243
  target: str | None = None,
244
244
  inline_threshold: int = 100,
245
245
  ) -> str:
246
- """Trace call flow from a starting symbol. Shows call chains with inline code for small functions. Use for 'how does X reach Y' queries, impact analysis, and understanding execution paths. Direction 'forward' traces callees (what this calls), 'backward' traces callers (what calls this)."""
246
+ """Trace call flow from a starting symbol. Shows call chains with inline code for small functions. Use for 'how does X reach Y' queries, impact analysis, and understanding execution paths. Direction 'forward' traces callees (what this calls), 'backward' traces callers (what calls this). depth is the levels of calls below the start, at most the project's [query] max_depth (default 3)."""
247
247
  return _fenced(
248
248
  await _svc().flow(start, direction.value, depth, target, inline_threshold)
249
249
  )