codebrain 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. {codebrain-0.4.0 → codebrain-0.4.1}/PKG-INFO +1 -1
  2. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/__init__.py +1 -1
  3. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/graph/store.py +14 -0
  4. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/indexer.py +3 -4
  5. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/mcp_server.py +27 -41
  6. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain.egg-info/PKG-INFO +1 -1
  7. {codebrain-0.4.0 → codebrain-0.4.1}/pyproject.toml +1 -1
  8. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_mcp_server.py +85 -11
  9. {codebrain-0.4.0 → codebrain-0.4.1}/LICENSE +0 -0
  10. {codebrain-0.4.0 → codebrain-0.4.1}/README.md +0 -0
  11. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/__main__.py +0 -0
  12. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/actions/__init__.py +0 -0
  13. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/actions/base.py +0 -0
  14. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/actions/refactor.py +0 -0
  15. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/actions/reviewer.py +0 -0
  16. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/actions/test_gen.py +0 -0
  17. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/agent_bridge.py +0 -0
  18. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/analyzer.py +0 -0
  19. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/api.py +0 -0
  20. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/api_models.py +0 -0
  21. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/architecture.py +0 -0
  22. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/cli.py +0 -0
  23. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/comprehension.py +0 -0
  24. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/config.py +0 -0
  25. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/context.py +0 -0
  26. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/cross_query.py +0 -0
  27. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/cross_registry.py +0 -0
  28. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/diff_impact.py +0 -0
  29. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/env_migration.py +0 -0
  30. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/equivalence.py +0 -0
  31. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/export.py +0 -0
  32. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/frontend.py +0 -0
  33. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/graph/__init__.py +0 -0
  34. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/graph/query.py +0 -0
  35. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/graph/schema.py +0 -0
  36. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/hook_runner.py +0 -0
  37. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/hooks.py +0 -0
  38. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/kt.py +0 -0
  39. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/kt_video.py +0 -0
  40. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/llm.py +0 -0
  41. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/logging.py +0 -0
  42. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/mcp_lifecycle.py +0 -0
  43. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/memory/__init__.py +0 -0
  44. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/memory/store.py +0 -0
  45. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/migration.py +0 -0
  46. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/modernize.py +0 -0
  47. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/onboard.py +0 -0
  48. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/__init__.py +0 -0
  49. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/base.py +0 -0
  50. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/cobol_parser.py +0 -0
  51. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/config_parser.py +0 -0
  52. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/csharp_parser.py +0 -0
  53. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/dart_parser.py +0 -0
  54. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/fortran_parser.py +0 -0
  55. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/frontend_parser.py +0 -0
  56. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/go_parser.py +0 -0
  57. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/java_parser.py +0 -0
  58. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/kotlin_parser.py +0 -0
  59. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/models.py +0 -0
  60. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/mumps_parser.py +0 -0
  61. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/plsql_parser.py +0 -0
  62. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/python_parser.py +0 -0
  63. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/registry.py +0 -0
  64. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/rust_parser.py +0 -0
  65. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/schema_parser.py +0 -0
  66. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/typescript_parser.py +0 -0
  67. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/typescript_treesitter.py +0 -0
  68. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/parser/vue_parser.py +0 -0
  69. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/py.typed +0 -0
  70. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/resolver.py +0 -0
  71. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/rewriter.py +0 -0
  72. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/schema_migration.py +0 -0
  73. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/settings.py +0 -0
  74. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/susa_auth.py +0 -0
  75. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/test_gaps.py +0 -0
  76. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/test_runner.py +0 -0
  77. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/tour.py +0 -0
  78. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/ui_migration.py +0 -0
  79. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/utils.py +0 -0
  80. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/validator.py +0 -0
  81. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/watcher/__init__.py +0 -0
  82. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain/watcher/file_watcher.py +0 -0
  83. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain.egg-info/SOURCES.txt +0 -0
  84. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain.egg-info/dependency_links.txt +0 -0
  85. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain.egg-info/entry_points.txt +0 -0
  86. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain.egg-info/requires.txt +0 -0
  87. {codebrain-0.4.0 → codebrain-0.4.1}/codebrain.egg-info/top_level.txt +0 -0
  88. {codebrain-0.4.0 → codebrain-0.4.1}/setup.cfg +0 -0
  89. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_actions.py +0 -0
  90. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_agent_bridge.py +0 -0
  91. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_analyzer.py +0 -0
  92. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_api.py +0 -0
  93. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_architecture.py +0 -0
  94. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_ci.py +0 -0
  95. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_cli.py +0 -0
  96. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_comprehension.py +0 -0
  97. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_context.py +0 -0
  98. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_contracts_real.py +0 -0
  99. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_coverage_gaps.py +0 -0
  100. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_cross_repo.py +0 -0
  101. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_csharp_parser.py +0 -0
  102. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_dart_parser.py +0 -0
  103. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_dataflow.py +0 -0
  104. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_dead_code_confidence.py +0 -0
  105. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_diff_impact.py +0 -0
  106. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_env_migration.py +0 -0
  107. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_equivalence.py +0 -0
  108. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_error_recovery.py +0 -0
  109. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_export.py +0 -0
  110. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_fingerprints.py +0 -0
  111. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_frontend.py +0 -0
  112. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_gate_battle.py +0 -0
  113. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_go_parser.py +0 -0
  114. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_hooks.py +0 -0
  115. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_indexer.py +0 -0
  116. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_infra_parser.py +0 -0
  117. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_install.py +0 -0
  118. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_integration.py +0 -0
  119. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_java_parser.py +0 -0
  120. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_jyotishyamitra.py +0 -0
  121. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_kotlin_parser.py +0 -0
  122. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_kt.py +0 -0
  123. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_legacy_parsers.py +0 -0
  124. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_llm.py +0 -0
  125. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_mcp_lifecycle.py +0 -0
  126. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_memory.py +0 -0
  127. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_migration.py +0 -0
  128. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_modernize.py +0 -0
  129. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_multi_project_cli.py +0 -0
  130. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_narratives.py +0 -0
  131. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_onboard.py +0 -0
  132. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_orm_detection.py +0 -0
  133. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_output_quality.py +0 -0
  134. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_parser.py +0 -0
  135. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_plugin_system.py +0 -0
  136. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_production_hardening.py +0 -0
  137. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_query.py +0 -0
  138. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_real_codebase.py +0 -0
  139. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_real_features.py +0 -0
  140. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_real_frontend.py +0 -0
  141. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_real_repos.py +0 -0
  142. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_real_world.py +0 -0
  143. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_resolver.py +0 -0
  144. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_rewriter.py +0 -0
  145. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_rust_parser.py +0 -0
  146. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_scale.py +0 -0
  147. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_scale_optimizations.py +0 -0
  148. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_scale_real.py +0 -0
  149. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_schema.py +0 -0
  150. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_schema_migration.py +0 -0
  151. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_schema_parser.py +0 -0
  152. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_settings.py +0 -0
  153. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_store.py +0 -0
  154. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_test_runner.py +0 -0
  155. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_tour.py +0 -0
  156. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_translate.py +0 -0
  157. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_ts_ast_parser.py +0 -0
  158. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_ts_parser_enhanced.py +0 -0
  159. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_typescript_parser.py +0 -0
  160. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_ui_migration.py +0 -0
  161. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_utils.py +0 -0
  162. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_validation_narratives.py +0 -0
  163. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_validator.py +0 -0
  164. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_validator_scenarios.py +0 -0
  165. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_vscode_extension.py +0 -0
  166. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_watch_validate.py +0 -0
  167. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_watcher.py +0 -0
  168. {codebrain-0.4.0 → codebrain-0.4.1}/tests/test_zoom.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebrain
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Know what breaks before you break it. Structural knowledge graph for codebases — impact analysis, dead code detection, health scores. No LLM required.
5
5
  Author: CodeBrain Contributors
6
6
  License: MIT License
@@ -7,4 +7,4 @@ try:
7
7
  except PackageNotFoundError:
8
8
  # Source checkout without installation (e.g. running from a worktree).
9
9
  # Keep in sync with [project.version] in pyproject.toml.
10
- __version__ = "0.4.0"
10
+ __version__ = "0.4.1"
@@ -102,6 +102,20 @@ class GraphStore:
102
102
  self.conn.execute("DELETE FROM nodes WHERE file_path = ?", (path,))
103
103
  self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
104
104
 
105
+ def remove_files(self, paths: list[str]) -> None:
106
+ """Delete many files and their nodes/edges in ONE transaction.
107
+
108
+ Per-file transactions take minutes when thousands of rows go stale
109
+ (e.g. a venv that used to be indexed); this takes well under a second.
110
+ """
111
+ if not paths:
112
+ return
113
+ params = [(p,) for p in paths]
114
+ with self.conn:
115
+ self.conn.executemany("DELETE FROM edges WHERE file_path = ?", params)
116
+ self.conn.executemany("DELETE FROM nodes WHERE file_path = ?", params)
117
+ self.conn.executemany("DELETE FROM files WHERE path = ?", params)
118
+
105
119
  def clear_all(self) -> None:
106
120
  """Delete all data from the graph (files, nodes, edges).
107
121
 
@@ -542,10 +542,9 @@ def incremental_update(
542
542
  removed = 0
543
543
  errors: list[str] = []
544
544
 
545
- for file_path in deleted_files:
546
- rel = normalize_path(file_path, repo_root)
547
- store.remove_file(rel)
548
- removed += 1
545
+ deleted_rels = [normalize_path(fp, repo_root) for fp in deleted_files]
546
+ store.remove_files(deleted_rels)
547
+ removed = len(deleted_rels)
549
548
 
550
549
  # tree-sitter can hang holding the GIL on Windows — isolate those
551
550
  # extensions in a subprocess, same as full_index. A hang here would
@@ -124,10 +124,10 @@ def _make_store():
124
124
  import threading as _threading
125
125
  import time as _time
126
126
 
127
- FRESHNESS_INTERVAL_SECONDS = float(os.environ.get("CODEBRAIN_FRESHNESS_INTERVAL", "5"))
128
- # Above this many stale files, sync in the background instead of inline so a
129
- # huge drift (e.g. branch switch on a big repo) can't eat the tool deadline.
130
- FRESHNESS_INLINE_LIMIT = 200
127
+ # Default 30s: the watcher handles live edits instantly; this gate is the
128
+ # convergence safety net. Measured walk cost is ~2-5s on a midsize repo
129
+ # a 5s interval would burn most of a core just rescanning.
130
+ FRESHNESS_INTERVAL_SECONDS = float(os.environ.get("CODEBRAIN_FRESHNESS_INTERVAL", "30"))
131
131
 
132
132
  _freshness_lock = _threading.Lock()
133
133
  _last_freshness_check: dict[str, float] = {}
@@ -157,10 +157,13 @@ _FRESHNESS_EXEMPT_TOOLS = frozenset({
157
157
 
158
158
 
159
159
  def _ensure_fresh() -> None:
160
- """Best-effort staleness sync before serving a tool call.
160
+ """Schedule a background freshness sync if one is due.
161
161
 
162
- Throttled per database; never blocks on a concurrent sync (a slightly
163
- stale answer beats a deadlocked one); never raises.
162
+ NEVER does disk walks, hashing, or parsing on the tool path. v0.4.0
163
+ ran the staleness scan inline and on large or venv-polluted repos the
164
+ walk alone exceeded the tool deadline — every query timed out. The
165
+ contract is: tool calls never wait; the index converges within
166
+ seconds via the background worker (single-flight, throttled per DB).
164
167
  """
165
168
  if FRESHNESS_INTERVAL_SECONDS <= 0:
166
169
  return
@@ -172,10 +175,24 @@ def _ensure_fresh() -> None:
172
175
  now = _time.monotonic()
173
176
  if now - _last_freshness_check.get(key, 0.0) < FRESHNESS_INTERVAL_SECONDS:
174
177
  return
178
+ # Single-flight: the lock is held for the WHOLE background operation
179
+ # and released by the worker. Without this, slow syncs pile up — a
180
+ # repo with thousands of stale rows spawned a new mega-sync every
181
+ # interval while the first was still running.
175
182
  if not _freshness_lock.acquire(blocking=False):
176
- return # another sync in flight — serve what we have
183
+ return # a sync is already in flight
184
+ _last_freshness_check[key] = now
185
+ _threading.Thread(
186
+ target=_freshness_worker,
187
+ args=(db_path,),
188
+ name="cb-freshness-sync",
189
+ daemon=True,
190
+ ).start()
191
+
192
+
193
+ def _freshness_worker(db_path: Path) -> None:
194
+ """Owns _freshness_lock; scans and syncs, then releases."""
177
195
  try:
178
- _last_freshness_check[key] = now
179
196
  from codebrain.graph.store import GraphStore
180
197
  from codebrain.indexer import incremental_update, scan_stale
181
198
 
@@ -184,22 +201,10 @@ def _ensure_fresh() -> None:
184
201
  changed, deleted = scan_stale(repo_root, store)
185
202
  if not changed and not deleted:
186
203
  return
187
- if len(changed) + len(deleted) > FRESHNESS_INLINE_LIMIT:
188
- _log.info(
189
- "Freshness: %d files drifted — syncing in background",
190
- len(changed) + len(deleted),
191
- )
192
- _threading.Thread(
193
- target=_background_freshness_sync,
194
- args=(repo_root, db_path, changed, deleted),
195
- name="cb-freshness-sync",
196
- daemon=True,
197
- ).start()
198
- return
199
204
  result = incremental_update(repo_root, changed, deleted, store)
200
205
  if result["files_updated"] or result["files_removed"]:
201
206
  _log.info(
202
- "Freshness: updated %d, removed %d (%.3fs)",
207
+ "Freshness sync: updated %d, removed %d (%.3fs)",
203
208
  result["files_updated"],
204
209
  result["files_removed"],
205
210
  result["elapsed_seconds"],
@@ -210,25 +215,6 @@ def _ensure_fresh() -> None:
210
215
  _freshness_lock.release()
211
216
 
212
217
 
213
- def _background_freshness_sync(
214
- repo_root: Path, db_path: Path, changed: list, deleted: list,
215
- ) -> None:
216
- try:
217
- from codebrain.graph.store import GraphStore
218
- from codebrain.indexer import incremental_update
219
-
220
- with GraphStore(db_path) as store:
221
- result = incremental_update(repo_root, changed, deleted, store)
222
- _log.info(
223
- "Background freshness sync: updated %d, removed %d (%.3fs)",
224
- result["files_updated"],
225
- result["files_removed"],
226
- result["elapsed_seconds"],
227
- )
228
- except Exception as exc:
229
- _log.warning("Background freshness sync failed: %s", exc)
230
-
231
-
232
218
  def _safe_tool(fn): # noqa: ANN001, ANN201
233
219
  """Wrap an MCP tool so it cooperates with MCP cancellation.
234
220
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebrain
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Know what breaks before you break it. Structural knowledge graph for codebases — impact analysis, dead code detection, health scores. No LLM required.
5
5
  Author: CodeBrain Contributors
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codebrain"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Know what breaks before you break it. Structural knowledge graph for codebases — impact analysis, dead code detection, health scores. No LLM required."
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -624,10 +624,20 @@ class TestMCPBadInput:
624
624
 
625
625
 
626
626
  class TestFreshnessOnRead:
627
- """Index drift must be invisible at the tool boundary — answers reflect
628
- current code regardless of which background watchers are alive."""
629
-
630
- def test_tool_call_sees_offline_edit(self, mcp_project):
627
+ """Index drift converges at the tool boundary — answers reflect current
628
+ code within seconds regardless of which background watchers are alive,
629
+ and the tool path itself NEVER waits on disk work."""
630
+
631
+ def _wait_for(self, predicate, timeout=15.0):
632
+ import time
633
+ deadline = time.time() + timeout
634
+ while time.time() < deadline:
635
+ if predicate():
636
+ return True
637
+ time.sleep(0.1)
638
+ return False
639
+
640
+ def test_tool_call_triggers_sync_of_offline_edit(self, mcp_project):
631
641
  import codebrain.mcp_server as ms
632
642
  repo_root, db_path = mcp_project
633
643
  ms._last_freshness_check.clear()
@@ -636,24 +646,62 @@ class TestFreshnessOnRead:
636
646
  "def hotfix_func():\n return 1\n"
637
647
  )
638
648
  from codebrain.mcp_server import search_symbol
639
- result = _run_async(search_symbol("hotfix_func"))
640
- data = json.loads(result)
641
- assert len(data) > 0, "freshness gate should have indexed the new file"
642
649
 
643
- def test_throttle_skips_rescan_within_interval(self, mcp_project, monkeypatch):
650
+ def _found():
651
+ data = json.loads(_run_async(search_symbol("hotfix_func")))
652
+ return len(data) > 0
653
+
654
+ assert self._wait_for(_found), (
655
+ "freshness worker should have indexed the offline edit within seconds"
656
+ )
657
+
658
+ def test_tool_path_does_no_disk_scan_inline(self, mcp_project, monkeypatch):
659
+ """THE v0.4.0 regression: the staleness scan ran inline and on big
660
+ repos the walk alone blew the tool deadline. The tool path must
661
+ return without ever calling scan_stale in the calling thread."""
662
+ import threading
644
663
  import codebrain.indexer as idx
645
664
  import codebrain.mcp_server as ms
646
- calls = []
665
+ scan_threads = []
647
666
  real = idx.scan_stale
648
667
  monkeypatch.setattr(
649
668
  idx, "scan_stale",
650
- lambda *a, **k: (calls.append(1), real(*a, **k))[1],
669
+ lambda *a, **k: (scan_threads.append(threading.current_thread().name), real(*a, **k))[1],
651
670
  )
652
671
  ms._last_freshness_check.clear()
653
672
  from codebrain.mcp_server import search_symbol
654
673
  _run_async(search_symbol("process"))
674
+ self._wait_for(lambda: len(scan_threads) >= 1)
675
+ assert scan_threads, "freshness worker never ran"
676
+ for name in scan_threads:
677
+ assert name == "cb-freshness-sync", (
678
+ f"scan_stale ran on thread {name!r} — disk walks are forbidden "
679
+ "on the tool path"
680
+ )
681
+
682
+ def test_syncs_do_not_pile_up(self, mcp_project, monkeypatch):
683
+ """Single-flight: while one sync is in flight, new tool calls must
684
+ not spawn additional syncs (mega-syncs used to stack every 5s)."""
685
+ import time
686
+ import codebrain.indexer as idx
687
+ import codebrain.mcp_server as ms
688
+ calls = []
689
+
690
+ def slow_scan(*a, **k):
691
+ calls.append(1)
692
+ time.sleep(1.0)
693
+ return [], []
694
+
695
+ monkeypatch.setattr(idx, "scan_stale", slow_scan)
696
+ monkeypatch.setattr(ms, "FRESHNESS_INTERVAL_SECONDS", 0.01)
697
+ ms._last_freshness_check.clear()
698
+ from codebrain.mcp_server import search_symbol
655
699
  _run_async(search_symbol("process"))
656
- assert len(calls) == 1, "second call within interval must not rescan"
700
+ time.sleep(0.1) # worker now inside slow_scan
701
+ _run_async(search_symbol("process"))
702
+ _run_async(search_symbol("process"))
703
+ time.sleep(1.2) # let the worker finish
704
+ assert len(calls) == 1, "concurrent syncs piled up"
657
705
 
658
706
  def test_validators_are_exempt(self):
659
707
  import codebrain.mcp_server as ms
@@ -661,3 +709,29 @@ class TestFreshnessOnRead:
661
709
  # freshness sync first would erase the baseline and kill the gate.
662
710
  for tool in ("validate_after_write", "validate_change", "propose_change"):
663
711
  assert tool in ms._FRESHNESS_EXEMPT_TOOLS
712
+
713
+
714
+ class TestBatchedDeletion:
715
+ def test_thousands_of_stale_rows_removed_fast(self, tmp_path):
716
+ """Stale-row cleanup at venv scale must be sub-second, not minutes."""
717
+ import time
718
+ from codebrain.graph.store import GraphStore
719
+ from codebrain.indexer import incremental_update
720
+ repo = tmp_path / "repo"
721
+ repo.mkdir()
722
+ db = repo / ".codebrain" / "graph.db"
723
+ with GraphStore(db) as store:
724
+ # Fabricate 5000 indexed files that no longer exist on disk
725
+ store.conn.executemany(
726
+ "INSERT INTO files (path, content_hash, last_indexed, line_count) "
727
+ "VALUES (?, 'x', 0, 1)",
728
+ [(f"stale/venv/mod_{i}.py",) for i in range(5000)],
729
+ )
730
+ store.conn.commit()
731
+ ghosts = [repo / f"stale/venv/mod_{i}.py" for i in range(5000)]
732
+ t0 = time.perf_counter()
733
+ result = incremental_update(repo, [], ghosts, store)
734
+ elapsed = time.perf_counter() - t0
735
+ assert result["files_removed"] == 5000
736
+ assert elapsed < 5.0, f"batched deletion took {elapsed:.1f}s"
737
+ assert store.all_file_paths() == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes