codeboarding 0.13.4__tar.gz → 0.13.5__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 (208) hide show
  1. {codeboarding-0.13.4/codeboarding.egg-info → codeboarding-0.13.5}/PKG-INFO +1 -1
  2. {codeboarding-0.13.4 → codeboarding-0.13.5/codeboarding.egg-info}/PKG-INFO +1 -1
  3. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding.egg-info/SOURCES.txt +2 -0
  4. {codeboarding-0.13.4 → codeboarding-0.13.5}/pyproject.toml +1 -1
  5. {codeboarding-0.13.4 → codeboarding-0.13.5}/repo_utils/ignore.py +15 -0
  6. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/__init__.py +18 -3
  7. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/csharp_adapter.py +5 -0
  8. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/call_graph_builder.py +28 -16
  9. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/edge_build_context.py +4 -0
  10. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/edge_builder.py +15 -2
  11. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/language_adapter.py +12 -0
  12. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/lsp_client.py +32 -1
  13. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/lsp_constants.py +12 -0
  14. codeboarding-0.13.5/static_analyzer/engine/lsp_recycler.py +142 -0
  15. codeboarding-0.13.5/static_analyzer/engine/process_memory.py +104 -0
  16. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/source_inspector.py +54 -13
  17. {codeboarding-0.13.4 → codeboarding-0.13.5}/LICENSE +0 -0
  18. {codeboarding-0.13.4 → codeboarding-0.13.5}/PYPI.md +0 -0
  19. {codeboarding-0.13.4 → codeboarding-0.13.5}/README.md +0 -0
  20. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/__init__.py +0 -0
  21. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/abstraction_agent.py +0 -0
  22. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/agent.py +0 -0
  23. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/agent_responses.py +0 -0
  24. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/change_status.py +0 -0
  25. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/cluster_ids.py +0 -0
  26. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/cluster_methods_mixin.py +0 -0
  27. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/constants.py +0 -0
  28. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/content_hash.py +0 -0
  29. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/dependency_discovery.py +0 -0
  30. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/details_agent.py +0 -0
  31. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/file_index_models.py +0 -0
  32. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/incremental_agent.py +0 -0
  33. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/incremental_results.py +0 -0
  34. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/llm_config.py +0 -0
  35. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/llm_errors.py +0 -0
  36. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/meta_agent.py +0 -0
  37. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/model_capabilities.py +0 -0
  38. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/planner_agent.py +0 -0
  39. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/__init__.py +0 -0
  40. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/abstract_prompt_factory.py +0 -0
  41. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/claude_prompts.py +0 -0
  42. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/deepseek_prompts.py +0 -0
  43. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/gemini_flash_prompts.py +0 -0
  44. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/glm_prompts.py +0 -0
  45. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/gpt_prompts.py +0 -0
  46. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/kimi_prompts.py +0 -0
  47. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/prompts/prompt_factory.py +0 -0
  48. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/relation_edges.py +0 -0
  49. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/repair.py +0 -0
  50. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/retry.py +0 -0
  51. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/scope_ids.py +0 -0
  52. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/__init__.py +0 -0
  53. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/base.py +0 -0
  54. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/component_bridge_edges.py +0 -0
  55. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/get_external_deps.py +0 -0
  56. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/get_method_invocations.py +0 -0
  57. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/list_git_changes.py +0 -0
  58. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/read_cfg.py +0 -0
  59. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/read_docs.py +0 -0
  60. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/read_file.py +0 -0
  61. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/read_file_structure.py +0 -0
  62. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/read_packages.py +0 -0
  63. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/read_source.py +0 -0
  64. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/read_structure.py +0 -0
  65. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/tools/toolkit.py +0 -0
  66. {codeboarding-0.13.4 → codeboarding-0.13.5}/agents/validation.py +0 -0
  67. {codeboarding-0.13.4 → codeboarding-0.13.5}/caching/__init__.py +0 -0
  68. {codeboarding-0.13.4 → codeboarding-0.13.5}/caching/cache.py +0 -0
  69. {codeboarding-0.13.4 → codeboarding-0.13.5}/caching/details_cache.py +0 -0
  70. {codeboarding-0.13.4 → codeboarding-0.13.5}/caching/meta_cache.py +0 -0
  71. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding.egg-info/dependency_links.txt +0 -0
  72. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding.egg-info/entry_points.txt +0 -0
  73. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding.egg-info/requires.txt +0 -0
  74. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding.egg-info/top_level.txt +0 -0
  75. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_cli/__init__.py +0 -0
  76. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_cli/bootstrap.py +0 -0
  77. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_cli/commands/__init__.py +0 -0
  78. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_cli/commands/full_analysis.py +0 -0
  79. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_cli/commands/incremental_analysis.py +0 -0
  80. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_cli/commands/partial_analysis.py +0 -0
  81. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_cli/view_instructions.py +0 -0
  82. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_workflows/__init__.py +0 -0
  83. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_workflows/analysis.py +0 -0
  84. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_workflows/orchestration.py +0 -0
  85. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_workflows/rendering.py +0 -0
  86. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_workflows/sources/__init__.py +0 -0
  87. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_workflows/sources/local.py +0 -0
  88. {codeboarding-0.13.4 → codeboarding-0.13.5}/codeboarding_workflows/sources/remote.py +0 -0
  89. {codeboarding-0.13.4 → codeboarding-0.13.5}/constants.py +0 -0
  90. {codeboarding-0.13.4 → codeboarding-0.13.5}/core/__init__.py +0 -0
  91. {codeboarding-0.13.4 → codeboarding-0.13.5}/core/plugin_loader.py +0 -0
  92. {codeboarding-0.13.4 → codeboarding-0.13.5}/core/protocols.py +0 -0
  93. {codeboarding-0.13.4 → codeboarding-0.13.5}/core/registry.py +0 -0
  94. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/__init__.py +0 -0
  95. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/analysis_json.py +0 -0
  96. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/cluster_delta.py +0 -0
  97. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/cluster_snapshot.py +0 -0
  98. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/diagram_generator.py +0 -0
  99. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/exceptions.py +0 -0
  100. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/file_coverage.py +0 -0
  101. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/file_index.py +0 -0
  102. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/io_utils.py +0 -0
  103. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/run_context.py +0 -0
  104. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/run_mode.py +0 -0
  105. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/scope_plan.py +0 -0
  106. {codeboarding-0.13.4 → codeboarding-0.13.5}/diagram_analysis/tree_shape.py +0 -0
  107. {codeboarding-0.13.4 → codeboarding-0.13.5}/github_action.py +0 -0
  108. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/__init__.py +0 -0
  109. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/__init__.py +0 -0
  110. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/circular_deps.py +0 -0
  111. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/cohesion.py +0 -0
  112. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/coupling.py +0 -0
  113. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/function_size.py +0 -0
  114. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/god_class.py +0 -0
  115. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/inheritance.py +0 -0
  116. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/instability.py +0 -0
  117. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/checks/unused_code_diagnostics.py +0 -0
  118. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/config.py +0 -0
  119. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/models.py +0 -0
  120. {codeboarding-0.13.4 → codeboarding-0.13.5}/health/runner.py +0 -0
  121. {codeboarding-0.13.4 → codeboarding-0.13.5}/install.py +0 -0
  122. {codeboarding-0.13.4 → codeboarding-0.13.5}/logging_config.py +0 -0
  123. {codeboarding-0.13.4 → codeboarding-0.13.5}/main.py +0 -0
  124. {codeboarding-0.13.4 → codeboarding-0.13.5}/monitoring/__init__.py +0 -0
  125. {codeboarding-0.13.4 → codeboarding-0.13.5}/monitoring/callbacks.py +0 -0
  126. {codeboarding-0.13.4 → codeboarding-0.13.5}/monitoring/context.py +0 -0
  127. {codeboarding-0.13.4 → codeboarding-0.13.5}/monitoring/mixin.py +0 -0
  128. {codeboarding-0.13.4 → codeboarding-0.13.5}/monitoring/paths.py +0 -0
  129. {codeboarding-0.13.4 → codeboarding-0.13.5}/monitoring/stats.py +0 -0
  130. {codeboarding-0.13.4 → codeboarding-0.13.5}/monitoring/writers.py +0 -0
  131. {codeboarding-0.13.4 → codeboarding-0.13.5}/output_generators/__init__.py +0 -0
  132. {codeboarding-0.13.4 → codeboarding-0.13.5}/output_generators/html.py +0 -0
  133. {codeboarding-0.13.4 → codeboarding-0.13.5}/output_generators/html_template.py +0 -0
  134. {codeboarding-0.13.4 → codeboarding-0.13.5}/output_generators/markdown.py +0 -0
  135. {codeboarding-0.13.4 → codeboarding-0.13.5}/output_generators/mdx.py +0 -0
  136. {codeboarding-0.13.4 → codeboarding-0.13.5}/output_generators/sphinx.py +0 -0
  137. {codeboarding-0.13.4 → codeboarding-0.13.5}/repo_utils/__init__.py +0 -0
  138. {codeboarding-0.13.4 → codeboarding-0.13.5}/repo_utils/change_detector.py +0 -0
  139. {codeboarding-0.13.4 → codeboarding-0.13.5}/repo_utils/errors.py +0 -0
  140. {codeboarding-0.13.4 → codeboarding-0.13.5}/repo_utils/fingerprint_diff.py +0 -0
  141. {codeboarding-0.13.4 → codeboarding-0.13.5}/repo_utils/git_ops.py +0 -0
  142. {codeboarding-0.13.4 → codeboarding-0.13.5}/repo_utils/path_utils.py +0 -0
  143. {codeboarding-0.13.4 → codeboarding-0.13.5}/setup.cfg +0 -0
  144. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/analysis_cache.py +0 -0
  145. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/analysis_result.py +0 -0
  146. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/cluster_helpers.py +0 -0
  147. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/cluster_relations.py +0 -0
  148. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/constants.py +0 -0
  149. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/csharp_config_scanner.py +0 -0
  150. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/dotnet_sdk.py +0 -0
  151. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/__init__.py +0 -0
  152. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/__init__.py +0 -0
  153. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/go_adapter.py +0 -0
  154. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/java_adapter.py +0 -0
  155. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/php_adapter.py +0 -0
  156. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/python_adapter.py +0 -0
  157. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/rust_adapter.py +0 -0
  158. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/adapters/typescript_adapter.py +0 -0
  159. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/hierarchy_builder.py +0 -0
  160. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/models.py +0 -0
  161. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/progress.py +0 -0
  162. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/protocols.py +0 -0
  163. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/result_converter.py +0 -0
  164. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/symbol_table.py +0 -0
  165. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/engine/utils.py +0 -0
  166. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/graph.py +0 -0
  167. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/incremental_orchestrator.py +0 -0
  168. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/internal_references.py +0 -0
  169. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/java_config_scanner.py +0 -0
  170. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/java_utils.py +0 -0
  171. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/language_results.py +0 -0
  172. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/leiden_utils.py +0 -0
  173. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/lsp_client/__init__.py +0 -0
  174. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/lsp_client/diagnostics.py +0 -0
  175. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/method_cluster_paths.py +0 -0
  176. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/node.py +0 -0
  177. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/programming_language.py +0 -0
  178. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/reference_resolver.py +0 -0
  179. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/scanner.py +0 -0
  180. {codeboarding-0.13.4 → codeboarding-0.13.5}/static_analyzer/typescript_config_scanner.py +0 -0
  181. {codeboarding-0.13.4 → codeboarding-0.13.5}/telemetry/__init__.py +0 -0
  182. {codeboarding-0.13.4 → codeboarding-0.13.5}/telemetry/device_id.py +0 -0
  183. {codeboarding-0.13.4 → codeboarding-0.13.5}/telemetry/events.py +0 -0
  184. {codeboarding-0.13.4 → codeboarding-0.13.5}/telemetry/schemas.py +0 -0
  185. {codeboarding-0.13.4 → codeboarding-0.13.5}/telemetry/service.py +0 -0
  186. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_cli_parser.py +0 -0
  187. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_github_action.py +0 -0
  188. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_install.py +0 -0
  189. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_logging_config.py +0 -0
  190. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_main.py +0 -0
  191. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_pyproject_packages.py +0 -0
  192. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_registry_coverage.py +0 -0
  193. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_telemetry_events.py +0 -0
  194. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_telemetry_service.py +0 -0
  195. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_tool_registry.py +0 -0
  196. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_user_config.py +0 -0
  197. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_view_instructions.py +0 -0
  198. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_vscode_constants.py +0 -0
  199. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_windows_compatibility.py +0 -0
  200. {codeboarding-0.13.4 → codeboarding-0.13.5}/tests/test_windows_encoding.py +0 -0
  201. {codeboarding-0.13.4 → codeboarding-0.13.5}/tool_registry/__init__.py +0 -0
  202. {codeboarding-0.13.4 → codeboarding-0.13.5}/tool_registry/installers.py +0 -0
  203. {codeboarding-0.13.4 → codeboarding-0.13.5}/tool_registry/manifest.py +0 -0
  204. {codeboarding-0.13.4 → codeboarding-0.13.5}/tool_registry/paths.py +0 -0
  205. {codeboarding-0.13.4 → codeboarding-0.13.5}/tool_registry/registry.py +0 -0
  206. {codeboarding-0.13.4 → codeboarding-0.13.5}/user_config.py +0 -0
  207. {codeboarding-0.13.4 → codeboarding-0.13.5}/utils.py +0 -0
  208. {codeboarding-0.13.4 → codeboarding-0.13.5}/vscode_constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeboarding
3
- Version: 0.13.4
3
+ Version: 0.13.5
4
4
  Summary: Interactive Diagrams for Code
5
5
  Author: CodeBoarding Team
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeboarding
3
- Version: 0.13.4
3
+ Version: 0.13.5
4
4
  Summary: Interactive Diagrams for Code
5
5
  Author: CodeBoarding Team
6
6
  License-Expression: MIT
@@ -160,7 +160,9 @@ static_analyzer/engine/hierarchy_builder.py
160
160
  static_analyzer/engine/language_adapter.py
161
161
  static_analyzer/engine/lsp_client.py
162
162
  static_analyzer/engine/lsp_constants.py
163
+ static_analyzer/engine/lsp_recycler.py
163
164
  static_analyzer/engine/models.py
165
+ static_analyzer/engine/process_memory.py
164
166
  static_analyzer/engine/progress.py
165
167
  static_analyzer/engine/protocols.py
166
168
  static_analyzer/engine/result_converter.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codeboarding"
7
- version = "0.13.4"
7
+ version = "0.13.5"
8
8
  description = "Interactive Diagrams for Code"
9
9
  readme = "PYPI.md"
10
10
  license = "MIT"
@@ -174,6 +174,7 @@ class RepoIgnoreManager:
174
174
 
175
175
  def reload(self):
176
176
  """Reload ignore patterns from .gitignore and .codeboardingignore."""
177
+ self._checked_paths: dict[str, bool] = {}
177
178
  gitignore_patterns = self._load_gitignore_patterns()
178
179
  codeboardingignore_patterns = self._load_codeboardingignore_patterns()
179
180
 
@@ -227,7 +228,21 @@ class RepoIgnoreManager:
227
228
  """Check if a given path should be ignored.
228
229
 
229
230
  Handles both absolute paths and paths relative to repo_root.
231
+
232
+ Records the result for each checked path until ``reload`` because callers
233
+ ask about the same files during discovery and again while converting
234
+ symbols and references.
230
235
  """
236
+ path_key = str(path)
237
+ should_ignore = self._checked_paths.get(path_key)
238
+ if should_ignore is not None:
239
+ return should_ignore
240
+
241
+ should_ignore = self._compute_should_ignore(path)
242
+ self._checked_paths[path_key] = should_ignore
243
+ return should_ignore
244
+
245
+ def _compute_should_ignore(self, path: Path) -> bool:
231
246
  try:
232
247
  # Convert to relative path if absolute
233
248
  if path.is_absolute():
@@ -232,6 +232,17 @@ class StaticAnalyzer:
232
232
 
233
233
  for engine_config in self._engine_configs:
234
234
  adapter, project_path = engine_config.adapter, engine_config.project_path
235
+ # Resolve file membership before paying for the server. A language
236
+ # the scanner detected from a handful of stray files still costs a
237
+ # full LSP process (tsserver + its node workers run ~350MB) that
238
+ # then analyzes nothing. Also reused by ``_run_full_analysis``, so
239
+ # the directory walk happens once per engine rather than twice.
240
+ if not engine_config.source_files:
241
+ engine_config.source_files = adapter.discover_source_files(project_path, self.ignore_manager)
242
+ if not engine_config.source_files:
243
+ logger.info(f"No {adapter.language} source files under {project_path}; skipping its LSP server.")
244
+ continue
245
+
235
246
  attempted.append(adapter.language)
236
247
  engine_client: LSPClient | None = None
237
248
  try:
@@ -289,12 +300,16 @@ class StaticAnalyzer:
289
300
  f"Error shutting down partially-started {adapter.language} client during cleanup"
290
301
  )
291
302
 
303
+ if not attempted:
304
+ logger.info(f"No source files for any detected language in {self.repository_path}; no LSP clients started.")
305
+ self._engine_clients = []
306
+ self._clients_started = True
307
+ return
308
+
292
309
  if not started:
293
310
  self._clients_started = False
294
311
  details = f"; failures: {'; '.join(failed_details)}" if failed_details else ""
295
- raise RuntimeError(
296
- f"Failed to start any engine LSP client (attempted: {', '.join(attempted) or 'none'}){details}"
297
- )
312
+ raise RuntimeError(f"Failed to start any engine LSP client (attempted: {', '.join(attempted)}){details}")
298
313
 
299
314
  if failed_languages:
300
315
  details = f" Details: {'; '.join(failed_details)}." if failed_details else ""
@@ -168,6 +168,11 @@ class CSharpAdapter(LanguageAdapter):
168
168
  """csharp-ls loads all files from the .sln — didOpen before workspace load kills it."""
169
169
  return True
170
170
 
171
+ @property
172
+ def workspace_owns_documents(self) -> bool:
173
+ """csharp-ls answers position queries from the loaded solution, opened or not."""
174
+ return True
175
+
171
176
  def get_lsp_default_timeout(self) -> int:
172
177
  """csharp-ls needs extra time to load Roslyn workspace for large solutions."""
173
178
  return 120
@@ -13,6 +13,7 @@ from static_analyzer.engine.hierarchy_builder import HierarchyBuilder
13
13
  from static_analyzer.engine.language_adapter import LanguageAdapter
14
14
  from static_analyzer.engine.lsp_client import LSPClient
15
15
  from static_analyzer.engine.lsp_constants import DID_OPEN_BATCH_SIZE, EdgeStrategy
16
+ from static_analyzer.engine.lsp_recycler import LSPRecycler
16
17
  from static_analyzer.engine.models import CallFlowGraph, LanguageAnalysisResult
17
18
  from static_analyzer.engine.source_inspector import SourceInspector
18
19
  from static_analyzer.engine.symbol_table import SymbolTable
@@ -60,7 +61,9 @@ class CallGraphBuilder:
60
61
  t_indices_done = time.monotonic()
61
62
  logger.info("Build indices: %.1fs", t_indices_done - t_symbols_done)
62
63
 
63
- ctx = EdgeBuildContext(self._lsp, self._symbol_table, self._source_inspector)
64
+ ctx = EdgeBuildContext(
65
+ self._lsp, self._symbol_table, self._source_inspector, recycler=self._build_recycler(source_files)
66
+ )
64
67
  edge_set = self._build_edges(ctx, source_files)
65
68
  edge_set = self._postprocess_edges(edge_set)
66
69
  t_edges_done = time.monotonic()
@@ -118,28 +121,37 @@ class CallGraphBuilder:
118
121
  source_files=abs_files,
119
122
  )
120
123
 
124
+ def _build_recycler(self, source_files: list[Path]) -> LSPRecycler | None:
125
+ """A recycler for servers whose memory the references phase would otherwise grow without bound."""
126
+ if not self._adapter.workspace_owns_documents or not source_files:
127
+ return None
128
+ return LSPRecycler(self._lsp, source_files[0], self._probe_timeout(len(source_files)))
129
+
121
130
  def _build_edges(self, ctx: EdgeBuildContext, source_files: list[Path]) -> EdgeMap:
122
131
  """Dispatch to the edge-building strategy specified by the adapter."""
123
132
  if self._adapter.edge_strategy == EdgeStrategy.DEFINITIONS:
124
133
  return build_edges_via_definitions(self._adapter, ctx, source_files)
125
134
  return build_edges_via_references(self._adapter, ctx, source_files)
126
135
 
127
- def _discover_symbols(self, source_files: list[Path]) -> None:
128
- """Phase 0+1: Synchronize with the server, open files, and extract symbols."""
129
- total = len(source_files)
136
+ def _probe_timeout(self, total_files: int) -> int:
137
+ """Seconds to allow a synchronization probe to block on LSP indexing.
130
138
 
131
- # Synchronization probe blocks until the LSP server has indexed
132
- # the project. Scales linearly with file count (no OS branching);
133
- # the per-file ceiling is picked conservatively to cover gopls on
134
- # AV-heavy Windows and APFS macOS without being loose enough to
135
- # let a hung LSP waste CI minutes. Adapters can raise the floor via
136
- # ``get_probe_timeout_minimum()`` (e.g. csharp-ls needs extra time
137
- # to load a Roslyn workspace).
139
+ Scales linearly with file count (no OS branching); the per-file ceiling
140
+ is picked conservatively to cover gopls on AV-heavy Windows and APFS
141
+ macOS without being loose enough to let a hung LSP waste CI minutes.
142
+ Adapters can raise the floor via ``get_probe_timeout_minimum()`` (e.g.
143
+ csharp-ls needs extra time to load a Roslyn workspace).
144
+ """
138
145
  _PROBE_STARTUP_BASE = 60 # seconds — LSP startup independent of file count
139
146
  _PROBE_PER_FILE = 2.0 # seconds — ceiling across OSes (Linux observed ~0.35s/file)
140
147
  _PROBE_MAX_TIMEOUT = 1800 # seconds — hard cap
141
- probe_timeout = int(min(_PROBE_STARTUP_BASE + total * _PROBE_PER_FILE, _PROBE_MAX_TIMEOUT))
142
- probe_timeout = max(probe_timeout, self._adapter.get_probe_timeout_minimum())
148
+ probe_timeout = int(min(_PROBE_STARTUP_BASE + total_files * _PROBE_PER_FILE, _PROBE_MAX_TIMEOUT))
149
+ return max(probe_timeout, self._adapter.get_probe_timeout_minimum())
150
+
151
+ def _discover_symbols(self, source_files: list[Path]) -> None:
152
+ """Phase 0+1: Synchronize with the server, open files, and extract symbols."""
153
+ total = len(source_files)
154
+ probe_timeout = self._probe_timeout(total)
143
155
 
144
156
  interleave_open = self._adapter.interleave_did_open_with_symbols
145
157
 
@@ -164,7 +176,7 @@ class CallGraphBuilder:
164
176
  # Interleaved adapters deliberately query again after didOpen so
165
177
  # that each overlay notification has a response barrier.
166
178
  should_reuse_probe = idx == 1 and not interleave_open
167
- if should_reuse_probe and probe_result is not None:
179
+ if should_reuse_probe:
168
180
  symbols = probe_result
169
181
  elif interleave_open:
170
182
  symbols = self._lsp.document_symbol(file_path, timeout=probe_timeout)
@@ -193,9 +205,9 @@ class CallGraphBuilder:
193
205
  pbar.finish()
194
206
  logger.info("did_open %d files: %.1fs", total, time.monotonic() - t_open_start)
195
207
 
196
- def _send_sync_probe(self, source_files: list[Path], probe_timeout: int) -> list[dict] | None:
208
+ def _send_sync_probe(self, source_files: list[Path], probe_timeout: int) -> list[dict]:
197
209
  """Send a documentSymbol probe to wait for the LSP server to finish indexing."""
198
- probe_result: list[dict] | None = None
210
+ probe_result: list[dict] = []
199
211
  logger.info("Waiting for LSP server indexing (timeout=%ds)...", probe_timeout)
200
212
  t_probe = time.monotonic()
201
213
  if source_files:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
 
7
7
  from static_analyzer.engine.lsp_client import LSPClient
8
+ from static_analyzer.engine.lsp_recycler import LSPRecycler
8
9
  from static_analyzer.engine.source_inspector import SourceInspector
9
10
  from static_analyzer.engine.symbol_table import SymbolTable
10
11
 
@@ -16,3 +17,6 @@ class EdgeBuildContext:
16
17
  lsp: LSPClient
17
18
  symbol_table: SymbolTable
18
19
  source_inspector: SourceInspector
20
+ # Set only for servers that may be restarted mid-phase; None means the
21
+ # strategy runs against a single server process for the whole phase.
22
+ recycler: LSPRecycler | None = None
@@ -85,8 +85,13 @@ def build_edges_via_references(
85
85
  skipped_positions = 0
86
86
 
87
87
  pbar = ProgressLogger("Phase 2 (edges)", total_unique, unit="pos")
88
- for batch_start in range(0, total_unique, batch_size):
89
- batch_positions = unique_positions[batch_start : batch_start + batch_size]
88
+ batch_start = 0
89
+ while batch_start < total_unique:
90
+ # The recycler samples the server here — it decides both whether to
91
+ # restart it and how many queries this batch may carry.
92
+ size = batch_size if ctx.recycler is None else ctx.recycler.before_batch(batch_size)
93
+ batch_positions = unique_positions[batch_start : batch_start + size]
94
+ batch_start += len(batch_positions)
90
95
 
91
96
  # Filter out positions from files that already produced LSP errors
92
97
  filtered_positions: list[tuple[str, int, int]] = []
@@ -140,6 +145,14 @@ def build_edges_via_references(
140
145
  refs_total,
141
146
  (1 - refs_call_sites / max(refs_total, 1)) * 100,
142
147
  )
148
+ if ctx.recycler is not None and ctx.recycler.recycle_count:
149
+ # Worth one line at the end: a recycled run answered some queries from a
150
+ # different server process than the one it started with.
151
+ logger.info(
152
+ "Phase 2 (edges): the language server was recycled %d time(s); %d batches ran shrunk under memory pressure",
153
+ ctx.recycler.recycle_count,
154
+ ctx.recycler.shrunk_batches,
155
+ )
143
156
  return edge_set
144
157
 
145
158
 
@@ -191,6 +191,18 @@ class LanguageAdapter(ABC):
191
191
  """If True, probe first, then pair each didOpen with its documentSymbol request."""
192
192
  return False
193
193
 
194
+ @property
195
+ def workspace_owns_documents(self) -> bool:
196
+ """If True, the server reads documents from the project, not from our didOpen.
197
+
198
+ Such a server answers position queries for files we never opened, which
199
+ makes a restarted process equivalent to the one it replaced — so the
200
+ references phase may recycle it to bound memory (see ``LSPRecycler``).
201
+ Servers that only know the documents we pushed must not be recycled:
202
+ they would come back empty.
203
+ """
204
+ return False
205
+
194
206
  def get_probe_timeout_minimum(self) -> int:
195
207
  """Return the minimum probe timeout in seconds for initial indexing.
196
208
 
@@ -234,6 +234,36 @@ class LSPClient:
234
234
  self._opened_uris.clear()
235
235
  self._doc_versions.clear()
236
236
 
237
+ def restart(self) -> None:
238
+ """Replace the server process with a fresh one, same configuration.
239
+
240
+ Used to release memory a server accumulated and will not give back (see
241
+ ``LSPRecycler``). Diagnostics already collected are kept — they describe
242
+ the source, not the process. Open-document state is not: the new server
243
+ has nothing open, so only callers whose server reads documents from the
244
+ project itself may restart mid-analysis. Readiness is the caller's to
245
+ re-establish; this client has no view of what ready means per server.
246
+ """
247
+ self.shutdown()
248
+ self._shutdown_event.clear()
249
+ self._server_ready.clear()
250
+ self._server_health = None
251
+ self._server_health_message = None
252
+ self._work_done_progress_titles.clear()
253
+ self._work_done_progress_failures.clear()
254
+ self._init_failed = False
255
+ # Drop responses the dead server never got to be asked about, so a
256
+ # stale id cannot satisfy a pending request on the new connection.
257
+ self._msg_queue = queue.Queue()
258
+ self.start()
259
+
260
+ @property
261
+ def pid(self) -> int | None:
262
+ """OS pid of the running server process, or None when it is not running."""
263
+ if self._process is None or self._process.poll() is not None:
264
+ return None
265
+ return self._process.pid
266
+
237
267
  # ---- Document management ----
238
268
 
239
269
  def did_open(self, file_path: Path, language_id: str) -> None:
@@ -299,7 +329,7 @@ class LSPClient:
299
329
  return result
300
330
  return []
301
331
 
302
- def references(self, file_path: Path, line: int, character: int) -> list[dict]:
332
+ def references(self, file_path: Path, line: int, character: int, timeout: int | None = None) -> list[dict]:
303
333
  """Find all references to the symbol at the given position."""
304
334
  result = self._send_request(
305
335
  "textDocument/references",
@@ -308,6 +338,7 @@ class LSPClient:
308
338
  "position": {"line": line, "character": character},
309
339
  "context": {"includeDeclaration": True},
310
340
  },
341
+ timeout=timeout,
311
342
  )
312
343
  if isinstance(result, list):
313
344
  return result
@@ -25,6 +25,18 @@ CALLABLE_KINDS: set[int] = {
25
25
  # Batch size for did_open to avoid overwhelming LSP servers
26
26
  DID_OPEN_BATCH_SIZE = 50
27
27
 
28
+ # Share of RAM the language server may hold before it gets recycled. This
29
+ # leaves room for Python, other language servers, and the operating system.
30
+ MEMORY_BUDGET_FRACTION = 0.4
31
+ MIN_MEMORY_BUDGET = 2 * 1024**3
32
+ MAX_MEMORY_BUDGET = 12 * 1024**3
33
+
34
+ # Reduce reference-query concurrency before reaching the recycle threshold.
35
+ PRESSURE_FRACTION = 0.5
36
+ PRESSURED_BATCH_DIVISOR = 10
37
+
38
+ MEMORY_BUDGET_ENV_VAR = "CODEBOARDING_LSP_MEMORY_BUDGET_MB"
39
+
28
40
 
29
41
  class EdgeStrategy(StrEnum):
30
42
  """Edge-building strategy selection for Phase 2."""
@@ -0,0 +1,142 @@
1
+ """Bounds language-server memory during the references phase by restarting it.
2
+
3
+ Answering ``textDocument/references`` across a large workspace makes Roslyn (and
4
+ comparable engines) materialize a compilation per project and keep every one.
5
+ Growth is linear in positions queried: on bitwarden/server csharp-ls climbed
6
+ past 11GB and the run was OOM-killed on a 16GB runner. GC tuning halves the
7
+ slope but the line still has no ceiling, so a big enough repo always wins.
8
+
9
+ Restarting the server between batches drops the whole accumulation and costs one
10
+ workspace reload. It is only sound for servers that read documents from the
11
+ project itself rather than from our ``didOpen`` overlays -- those answer position
12
+ queries for files we never opened, so a fresh process is equivalent to the old
13
+ one. Adapters declare that via ``LanguageAdapter.workspace_owns_documents``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from static_analyzer.engine.lsp_client import LSPClient
24
+ from static_analyzer.engine.lsp_constants import (
25
+ MAX_MEMORY_BUDGET,
26
+ MEMORY_BUDGET_ENV_VAR,
27
+ MEMORY_BUDGET_FRACTION,
28
+ MIN_MEMORY_BUDGET,
29
+ PRESSURE_FRACTION,
30
+ PRESSURED_BATCH_DIVISOR,
31
+ )
32
+ from static_analyzer.engine.process_memory import format_bytes, physical_memory_bytes, process_tree_rss
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def default_memory_budget() -> int:
38
+ """Bytes a language server may occupy before it gets recycled."""
39
+ override = os.environ.get(MEMORY_BUDGET_ENV_VAR, "").strip()
40
+ if override:
41
+ try:
42
+ return int(float(override) * 1024**2)
43
+ except ValueError:
44
+ logger.warning("Ignoring %s=%r: not a number of megabytes", MEMORY_BUDGET_ENV_VAR, override)
45
+ physical = physical_memory_bytes()
46
+ if not physical:
47
+ return MIN_MEMORY_BUDGET
48
+ return int(min(max(physical * MEMORY_BUDGET_FRACTION, MIN_MEMORY_BUDGET), MAX_MEMORY_BUDGET))
49
+
50
+
51
+ class LSPRecycler:
52
+ """Restarts a language server whose process tree outgrows the budget."""
53
+
54
+ def __init__(
55
+ self,
56
+ lsp: LSPClient,
57
+ probe_file: Path,
58
+ probe_timeout: int,
59
+ budget_bytes: int = 0,
60
+ ) -> None:
61
+ self._lsp = lsp
62
+ self._probe_file = probe_file
63
+ self._probe_timeout = probe_timeout
64
+ self._budget = budget_bytes or default_memory_budget()
65
+ self.recycle_count = 0
66
+ self.shrunk_batches = 0
67
+ self._disarmed = False
68
+ logger.info("LSP recycler armed at a %s budget", format_bytes(self._budget))
69
+
70
+ def before_batch(self, full_batch_size: int) -> int:
71
+ """Sample the server, restart it if over budget, and size the next batch.
72
+
73
+ One sample per batch, not per N batches: a single batch of solution-wide
74
+ reference queries can allocate gigabytes, so a coarser interval sails
75
+ past the budget between checks. Reading the process table costs
76
+ milliseconds against a batch's own LSP round-trip.
77
+
78
+ Returns how many positions the next batch may carry. A batch is one
79
+ round-trip we cannot interrupt, so its own growth is the floor under any
80
+ bound we can offer; near the ceiling we carry fewer queries at once to
81
+ keep that floor low.
82
+ """
83
+ if self._disarmed:
84
+ return full_batch_size
85
+ used = self._server_rss()
86
+ if used >= self._budget:
87
+ self._recycle(used)
88
+ return full_batch_size
89
+ if used < self._budget * PRESSURE_FRACTION:
90
+ return full_batch_size
91
+ self.shrunk_batches += 1
92
+ return max(1, full_batch_size // PRESSURED_BATCH_DIVISOR)
93
+
94
+ def _recycle(self, used: int) -> None:
95
+ logger.warning(
96
+ "LSP holding %s (budget %s) — restarting it to release cached compilations",
97
+ format_bytes(used),
98
+ format_bytes(self._budget),
99
+ )
100
+ t_restart = time.monotonic()
101
+ self._lsp.restart()
102
+ # Blocks until the workspace is loaded again: a workspace-backed server
103
+ # cannot answer a documentSymbol until it has read the project files.
104
+ self._lsp.document_symbol(self._probe_file, timeout=self._probe_timeout)
105
+ # A loaded workspace is not a warm reference index — that is built on the
106
+ # first references query. Pay for it here, on a request we can give a long
107
+ # timeout, rather than losing a whole batch of real positions to the
108
+ # per-request deadline. Measured: recycling without this tripled the
109
+ # "Timeout waiting for references request" warnings.
110
+ try:
111
+ self._lsp.references(self._probe_file, 0, 0, timeout=self._probe_timeout)
112
+ except Exception as exc:
113
+ logger.debug("Post-restart references warmup failed (non-fatal): %s", exc)
114
+ self.recycle_count += 1
115
+ reloaded = self._server_rss()
116
+ logger.info(
117
+ "LSP restarted in %.0fs: %s -> %s (recycle #%d)",
118
+ time.monotonic() - t_restart,
119
+ format_bytes(used),
120
+ format_bytes(reloaded),
121
+ self.recycle_count,
122
+ )
123
+ if reloaded >= self._budget:
124
+ # A workspace whose freshly-loaded footprint already exceeds the
125
+ # budget cannot be helped by restarting: every batch would trigger
126
+ # another reload and the phase would make no progress. Say so and
127
+ # get out of the way — the budget is a fraction of RAM, so running
128
+ # on may still fit, and a livelock certainly will not.
129
+ self._disarmed = True
130
+ logger.warning(
131
+ "Loading this workspace alone needs %s, at or above the %s budget — memory can no longer be bounded. "
132
+ "Raise %s or analyze on a host with more RAM.",
133
+ format_bytes(reloaded),
134
+ format_bytes(self._budget),
135
+ MEMORY_BUDGET_ENV_VAR,
136
+ )
137
+
138
+ def _server_rss(self) -> int:
139
+ pid = self._lsp.pid
140
+ if pid is None:
141
+ return 0
142
+ return process_tree_rss(pid)
@@ -0,0 +1,104 @@
1
+ """Process-tree memory helpers used to enforce the LSP budget."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+
7
+ _PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
8
+ _IS_LINUX = sys.platform.startswith("linux")
9
+
10
+
11
+ def physical_memory_bytes() -> int:
12
+ """Return total physical memory, or zero when it cannot be determined."""
13
+ try:
14
+ return os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
15
+ except (ValueError, OSError, AttributeError):
16
+ return 0
17
+
18
+
19
+ def format_bytes(num_bytes: float) -> str:
20
+ """Render a byte count as a short human-readable string."""
21
+ for unit in ("B", "KB", "MB", "GB"):
22
+ if abs(num_bytes) < 1024.0:
23
+ return f"{num_bytes:.0f}{unit}" if unit == "B" else f"{num_bytes:.1f}{unit}"
24
+ num_bytes /= 1024.0
25
+ return f"{num_bytes:.1f}TB"
26
+
27
+
28
+ def _linux_process_table() -> dict[int, tuple[int, int]]:
29
+ """Return each readable Linux process as ``{pid: (ppid, rss_bytes)}``."""
30
+ table: dict[int, tuple[int, int]] = {}
31
+ try:
32
+ entries = os.listdir("/proc")
33
+ except OSError:
34
+ return table
35
+
36
+ for entry in entries:
37
+ if not entry.isdigit():
38
+ continue
39
+ pid = int(entry)
40
+ try:
41
+ with open(f"/proc/{pid}/stat", "rb") as handle:
42
+ stat = handle.read().decode("utf-8", errors="replace")
43
+ except OSError:
44
+ continue
45
+
46
+ close_paren = stat.rfind(")")
47
+ if close_paren == -1:
48
+ continue
49
+ fields = stat[close_paren + 2 :].split()
50
+ if len(fields) < 22:
51
+ continue
52
+ try:
53
+ table[pid] = (int(fields[1]), int(fields[21]) * _PAGE_SIZE)
54
+ except ValueError:
55
+ continue
56
+ return table
57
+
58
+
59
+ def _ps_process_table() -> dict[int, tuple[int, int]]:
60
+ """Return each readable macOS/BSD process as ``{pid: (ppid, rss_bytes)}``."""
61
+ table: dict[int, tuple[int, int]] = {}
62
+ try:
63
+ output = subprocess.run(
64
+ ["ps", "-axo", "pid=,ppid=,rss="],
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=20,
68
+ ).stdout
69
+ except (OSError, subprocess.SubprocessError):
70
+ return table
71
+
72
+ for line in output.splitlines():
73
+ parts = line.split()
74
+ if len(parts) != 3:
75
+ continue
76
+ try:
77
+ pid, ppid, rss_kb = int(parts[0]), int(parts[1]), int(parts[2])
78
+ except ValueError:
79
+ continue
80
+ table[pid] = (ppid, rss_kb * 1024)
81
+ return table
82
+
83
+
84
+ def process_tree_rss(root_pid: int) -> int:
85
+ """Return resident memory held by a process and all its descendants."""
86
+ table = _linux_process_table() if _IS_LINUX else _ps_process_table()
87
+ if root_pid not in table:
88
+ return 0
89
+
90
+ children_by_parent: dict[int, list[int]] = {}
91
+ for pid, (parent_pid, _) in table.items():
92
+ children_by_parent.setdefault(parent_pid, []).append(pid)
93
+
94
+ total = 0
95
+ stack = [root_pid]
96
+ seen: set[int] = set()
97
+ while stack:
98
+ pid = stack.pop()
99
+ if pid in seen:
100
+ continue
101
+ seen.add(pid)
102
+ total += table[pid][1]
103
+ stack.extend(children_by_parent.get(pid, []))
104
+ return total