codebrain 0.3.4__tar.gz → 0.3.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 (169) hide show
  1. {codebrain-0.3.4 → codebrain-0.3.5}/PKG-INFO +1 -1
  2. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/__init__.py +1 -1
  3. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/graph/store.py +5 -0
  4. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/mcp_lifecycle.py +41 -14
  5. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/watcher/file_watcher.py +76 -3
  6. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain.egg-info/PKG-INFO +1 -1
  7. {codebrain-0.3.4 → codebrain-0.3.5}/pyproject.toml +1 -1
  8. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_mcp_lifecycle.py +28 -3
  9. codebrain-0.3.5/tests/test_watcher.py +244 -0
  10. codebrain-0.3.4/tests/test_watcher.py +0 -135
  11. {codebrain-0.3.4 → codebrain-0.3.5}/LICENSE +0 -0
  12. {codebrain-0.3.4 → codebrain-0.3.5}/README.md +0 -0
  13. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/__main__.py +0 -0
  14. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/actions/__init__.py +0 -0
  15. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/actions/base.py +0 -0
  16. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/actions/refactor.py +0 -0
  17. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/actions/reviewer.py +0 -0
  18. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/actions/test_gen.py +0 -0
  19. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/agent_bridge.py +0 -0
  20. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/analyzer.py +0 -0
  21. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/api.py +0 -0
  22. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/api_models.py +0 -0
  23. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/architecture.py +0 -0
  24. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/cli.py +0 -0
  25. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/comprehension.py +0 -0
  26. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/config.py +0 -0
  27. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/context.py +0 -0
  28. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/cross_query.py +0 -0
  29. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/cross_registry.py +0 -0
  30. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/diff_impact.py +0 -0
  31. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/env_migration.py +0 -0
  32. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/equivalence.py +0 -0
  33. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/export.py +0 -0
  34. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/frontend.py +0 -0
  35. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/graph/__init__.py +0 -0
  36. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/graph/query.py +0 -0
  37. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/graph/schema.py +0 -0
  38. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/hook_runner.py +0 -0
  39. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/hooks.py +0 -0
  40. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/indexer.py +0 -0
  41. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/kt.py +0 -0
  42. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/kt_video.py +0 -0
  43. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/llm.py +0 -0
  44. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/logging.py +0 -0
  45. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/mcp_server.py +0 -0
  46. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/memory/__init__.py +0 -0
  47. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/memory/store.py +0 -0
  48. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/migration.py +0 -0
  49. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/modernize.py +0 -0
  50. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/onboard.py +0 -0
  51. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/__init__.py +0 -0
  52. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/base.py +0 -0
  53. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/cobol_parser.py +0 -0
  54. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/config_parser.py +0 -0
  55. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/csharp_parser.py +0 -0
  56. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/dart_parser.py +0 -0
  57. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/fortran_parser.py +0 -0
  58. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/frontend_parser.py +0 -0
  59. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/go_parser.py +0 -0
  60. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/java_parser.py +0 -0
  61. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/kotlin_parser.py +0 -0
  62. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/models.py +0 -0
  63. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/mumps_parser.py +0 -0
  64. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/plsql_parser.py +0 -0
  65. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/python_parser.py +0 -0
  66. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/registry.py +0 -0
  67. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/rust_parser.py +0 -0
  68. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/schema_parser.py +0 -0
  69. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/typescript_parser.py +0 -0
  70. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/typescript_treesitter.py +0 -0
  71. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/parser/vue_parser.py +0 -0
  72. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/py.typed +0 -0
  73. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/resolver.py +0 -0
  74. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/rewriter.py +0 -0
  75. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/schema_migration.py +0 -0
  76. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/settings.py +0 -0
  77. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/susa_auth.py +0 -0
  78. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/test_gaps.py +0 -0
  79. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/test_runner.py +0 -0
  80. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/tour.py +0 -0
  81. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/ui_migration.py +0 -0
  82. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/utils.py +0 -0
  83. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/validator.py +0 -0
  84. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain/watcher/__init__.py +0 -0
  85. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain.egg-info/SOURCES.txt +0 -0
  86. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain.egg-info/dependency_links.txt +0 -0
  87. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain.egg-info/entry_points.txt +0 -0
  88. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain.egg-info/requires.txt +0 -0
  89. {codebrain-0.3.4 → codebrain-0.3.5}/codebrain.egg-info/top_level.txt +0 -0
  90. {codebrain-0.3.4 → codebrain-0.3.5}/setup.cfg +0 -0
  91. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_actions.py +0 -0
  92. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_agent_bridge.py +0 -0
  93. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_analyzer.py +0 -0
  94. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_api.py +0 -0
  95. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_architecture.py +0 -0
  96. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_ci.py +0 -0
  97. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_cli.py +0 -0
  98. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_comprehension.py +0 -0
  99. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_context.py +0 -0
  100. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_contracts_real.py +0 -0
  101. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_coverage_gaps.py +0 -0
  102. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_cross_repo.py +0 -0
  103. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_csharp_parser.py +0 -0
  104. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_dart_parser.py +0 -0
  105. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_dataflow.py +0 -0
  106. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_dead_code_confidence.py +0 -0
  107. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_diff_impact.py +0 -0
  108. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_env_migration.py +0 -0
  109. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_equivalence.py +0 -0
  110. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_error_recovery.py +0 -0
  111. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_export.py +0 -0
  112. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_fingerprints.py +0 -0
  113. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_frontend.py +0 -0
  114. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_gate_battle.py +0 -0
  115. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_go_parser.py +0 -0
  116. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_hooks.py +0 -0
  117. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_indexer.py +0 -0
  118. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_infra_parser.py +0 -0
  119. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_install.py +0 -0
  120. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_integration.py +0 -0
  121. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_java_parser.py +0 -0
  122. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_jyotishyamitra.py +0 -0
  123. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_kotlin_parser.py +0 -0
  124. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_kt.py +0 -0
  125. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_legacy_parsers.py +0 -0
  126. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_llm.py +0 -0
  127. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_mcp_server.py +0 -0
  128. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_memory.py +0 -0
  129. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_migration.py +0 -0
  130. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_modernize.py +0 -0
  131. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_multi_project_cli.py +0 -0
  132. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_narratives.py +0 -0
  133. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_onboard.py +0 -0
  134. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_orm_detection.py +0 -0
  135. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_output_quality.py +0 -0
  136. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_parser.py +0 -0
  137. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_plugin_system.py +0 -0
  138. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_production_hardening.py +0 -0
  139. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_query.py +0 -0
  140. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_real_codebase.py +0 -0
  141. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_real_features.py +0 -0
  142. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_real_frontend.py +0 -0
  143. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_real_repos.py +0 -0
  144. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_real_world.py +0 -0
  145. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_resolver.py +0 -0
  146. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_rewriter.py +0 -0
  147. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_rust_parser.py +0 -0
  148. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_scale.py +0 -0
  149. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_scale_optimizations.py +0 -0
  150. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_scale_real.py +0 -0
  151. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_schema.py +0 -0
  152. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_schema_migration.py +0 -0
  153. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_schema_parser.py +0 -0
  154. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_settings.py +0 -0
  155. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_store.py +0 -0
  156. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_test_runner.py +0 -0
  157. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_tour.py +0 -0
  158. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_translate.py +0 -0
  159. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_ts_ast_parser.py +0 -0
  160. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_ts_parser_enhanced.py +0 -0
  161. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_typescript_parser.py +0 -0
  162. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_ui_migration.py +0 -0
  163. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_utils.py +0 -0
  164. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_validation_narratives.py +0 -0
  165. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_validator.py +0 -0
  166. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_validator_scenarios.py +0 -0
  167. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_vscode_extension.py +0 -0
  168. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_watch_validate.py +0 -0
  169. {codebrain-0.3.4 → codebrain-0.3.5}/tests/test_zoom.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebrain
3
- Version: 0.3.4
3
+ Version: 0.3.5
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.3.4"
10
+ __version__ = "0.3.5"
@@ -113,6 +113,11 @@ class GraphStore:
113
113
  ).fetchone()
114
114
  return row["content_hash"] if row else None
115
115
 
116
+ def all_file_paths(self) -> list[str]:
117
+ """Return every file path currently in the index."""
118
+ rows = self.conn.execute("SELECT path FROM files").fetchall()
119
+ return [row["path"] for row in rows]
120
+
116
121
  # ------------------------------------------------------------------
117
122
  # Node operations
118
123
  # ------------------------------------------------------------------
@@ -167,7 +167,7 @@ def _remove_pid_file(pid_file: Path) -> None:
167
167
  pass
168
168
 
169
169
 
170
- def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
170
+ def _find_watch_target(start_pid: int) -> tuple[int, float | None, bool]:
171
171
  """Pick the PID whose death should kill the MCP.
172
172
 
173
173
  Walks up the ancestor chain (up to ANCESTOR_WALK_DEPTH levels) looking
@@ -175,6 +175,10 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
175
175
  real IDE host. Falls back to ``start_pid`` if no hint matches, psutil
176
176
  is unavailable, or ``CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK=1`` is set.
177
177
 
178
+ Returns ``(pid, create_time, host_anchored)``. ``host_anchored`` is True
179
+ only when an actual IDE host process was found — callers use it to decide
180
+ whether the idle-timeout backstop is needed at all.
181
+
178
182
  Why: on Windows, Claude Code spawns the MCP via a transient launcher
179
183
  (cmd.exe wrapper or Electron worker shell). The launcher exits soon
180
184
  after the python child starts, so watching ``os.getppid()`` directly
@@ -183,16 +187,16 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
183
187
  try:
184
188
  import psutil
185
189
  except ImportError:
186
- return start_pid, None
190
+ return start_pid, None, False
187
191
 
188
192
  fallback_create_time: float | None = None
189
193
  try:
190
194
  fallback_create_time = psutil.Process(start_pid).create_time()
191
195
  except (psutil.NoSuchProcess, psutil.AccessDenied):
192
- return start_pid, None
196
+ return start_pid, None, False
193
197
 
194
198
  if os.environ.get("CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK") == "1":
195
- return start_pid, fallback_create_time
199
+ return start_pid, fallback_create_time, False
196
200
 
197
201
  try:
198
202
  proc = psutil.Process(start_pid)
@@ -202,7 +206,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
202
206
  except (psutil.NoSuchProcess, psutil.AccessDenied):
203
207
  break
204
208
  if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
205
- return proc.pid, proc.create_time()
209
+ return proc.pid, proc.create_time(), True
206
210
  try:
207
211
  parent = proc.parent()
208
212
  except (psutil.NoSuchProcess, psutil.AccessDenied):
@@ -212,7 +216,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
212
216
  proc = parent
213
217
  except (psutil.NoSuchProcess, psutil.AccessDenied):
214
218
  pass
215
- return start_pid, fallback_create_time
219
+ return start_pid, fallback_create_time, False
216
220
 
217
221
 
218
222
  def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> None:
@@ -241,16 +245,35 @@ def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> No
241
245
  _log.debug("parent watchdog tick error: %s", exc)
242
246
 
243
247
 
244
- def _idle_watchdog() -> None:
245
- if IDLE_TIMEOUT_SECONDS <= 0:
248
+ def _effective_idle_timeout(host_anchored: bool) -> int:
249
+ """Resolve the idle timeout in seconds (0 disables the idle watchdog).
250
+
251
+ The idle watchdog is a *backstop* for when the parent watchdog has no
252
+ reliable IDE host to watch. When we are anchored to a real host
253
+ (claude/cursor/vscode), the parent watchdog deterministically tears the
254
+ server down at session end — an idle timeout on top of that only kills
255
+ the file watcher mid-session and leaves the index stale. So:
256
+
257
+ - explicit ``CODEBRAIN_MCP_IDLE_TIMEOUT`` env var always wins
258
+ - otherwise: disabled when host-anchored, 30-min backstop when not
259
+ """
260
+ if "CODEBRAIN_MCP_IDLE_TIMEOUT" in os.environ:
261
+ return IDLE_TIMEOUT_SECONDS
262
+ if host_anchored:
263
+ return 0
264
+ return IDLE_TIMEOUT_SECONDS
265
+
266
+
267
+ def _idle_watchdog(timeout: int) -> None:
268
+ if timeout <= 0:
246
269
  return
247
- poll = max(5, min(60, IDLE_TIMEOUT_SECONDS // 4))
270
+ poll = max(5, min(60, timeout // 4))
248
271
  while True:
249
272
  time.sleep(poll)
250
273
  with _last_activity_lock:
251
274
  idle = time.time() - _last_activity
252
- if idle > IDLE_TIMEOUT_SECONDS:
253
- _exit(f"idle for {idle:.0f}s (limit {IDLE_TIMEOUT_SECONDS}s)")
275
+ if idle > timeout:
276
+ _exit(f"idle for {idle:.0f}s (limit {timeout}s)")
254
277
 
255
278
 
256
279
  def _lifetime_watchdog(start: float) -> None:
@@ -285,13 +308,16 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
285
308
  atexit.register(_remove_pid_file, pid_file)
286
309
 
287
310
  immediate_ppid = os.getppid()
288
- initial_ppid, initial_create_time = _find_watch_target(immediate_ppid)
311
+ initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
312
+ idle_timeout = _effective_idle_timeout(host_anchored)
289
313
 
290
314
  _log.info(
291
- "MCP watchdogs installed (ppid=%d via=%d, idle_timeout=%ds, max_lifetime=%ds)",
315
+ "MCP watchdogs installed (ppid=%d via=%d, host_anchored=%s, "
316
+ "idle_timeout=%ds, max_lifetime=%ds)",
292
317
  initial_ppid,
293
318
  immediate_ppid,
294
- IDLE_TIMEOUT_SECONDS,
319
+ host_anchored,
320
+ idle_timeout,
295
321
  MAX_LIFETIME_SECONDS,
296
322
  )
297
323
 
@@ -303,6 +329,7 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
303
329
  ).start()
304
330
  threading.Thread(
305
331
  target=_idle_watchdog,
332
+ args=(idle_timeout,),
306
333
  name="cb-idle-watchdog",
307
334
  daemon=True,
308
335
  ).start()
@@ -11,9 +11,10 @@ from watchdog.observers import Observer
11
11
 
12
12
  from codebrain.config import INDEXABLE_EXTENSIONS, WATCHER_DEBOUNCE_SECONDS
13
13
  from codebrain.graph.store import GraphStore
14
- from codebrain.indexer import incremental_update
14
+ from codebrain.indexer import discover_files, incremental_update
15
+ from codebrain.utils import normalize_path
15
16
  from codebrain.logging import get_logger
16
- from codebrain.settings import load_settings
17
+ from codebrain.settings import Settings, load_settings
17
18
 
18
19
  _log = get_logger("watcher")
19
20
 
@@ -37,6 +38,8 @@ class _DebouncedHandler(FileSystemEventHandler):
37
38
  self._changed: set[Path] = set()
38
39
  self._deleted: set[Path] = set()
39
40
  self._lock = threading.Lock()
41
+ # Serializes DB writes between flush timer threads and the catch-up thread.
42
+ self.db_lock = threading.Lock()
40
43
  self._timer: threading.Timer | None = None
41
44
  self._last_validation: dict[str, object] = {} # rel_path -> ValidationReport
42
45
 
@@ -57,6 +60,14 @@ class _DebouncedHandler(FileSystemEventHandler):
57
60
  if not changed and not deleted:
58
61
  return
59
62
 
63
+ # Live file edits count as activity — without this the idle watchdog
64
+ # kills the MCP (and this watcher with it) mid-editing-session.
65
+ try:
66
+ from codebrain.mcp_lifecycle import mark_activity
67
+ mark_activity()
68
+ except Exception:
69
+ pass
70
+
60
71
  # Redirect stdout to stderr to prevent MCP stdio protocol corruption
61
72
  # when running inside the MCP server process.
62
73
  import sys
@@ -67,7 +78,8 @@ class _DebouncedHandler(FileSystemEventHandler):
67
78
  if changed:
68
79
  self._validate_changed(changed)
69
80
 
70
- result = incremental_update(self.repo_root, changed, deleted, self.store)
81
+ with self.db_lock:
82
+ result = incremental_update(self.repo_root, changed, deleted, self.store)
71
83
  total = result["files_updated"] + result["files_removed"]
72
84
  if total:
73
85
  _log.info(
@@ -147,6 +159,59 @@ class _DebouncedHandler(FileSystemEventHandler):
147
159
  self._schedule_flush()
148
160
 
149
161
 
162
+ def catch_up_sync(
163
+ repo_root: Path,
164
+ store: GraphStore,
165
+ settings: Settings | None = None,
166
+ db_lock: threading.Lock | None = None,
167
+ ) -> dict:
168
+ """Bring the index up to date with changes made while no watcher was alive.
169
+
170
+ The file watcher only sees events that happen while its process is
171
+ running. Edits made between sessions (or after a lifecycle watchdog
172
+ killed the MCP server) are otherwise missed forever, leaving the index
173
+ stale until a manual `brain reindex`. This diffs disk vs. index:
174
+
175
+ - changed/new files: detected by content hash inside incremental_update
176
+ - deleted files: indexed paths that no longer exist on disk
177
+
178
+ Returns the incremental_update summary dict.
179
+ """
180
+ on_disk = discover_files(repo_root, settings)
181
+ disk_rels = {normalize_path(p, repo_root) for p in on_disk}
182
+ deleted = [
183
+ repo_root / rel
184
+ for rel in store.all_file_paths()
185
+ if rel not in disk_rels
186
+ ]
187
+ lock = db_lock if db_lock is not None else threading.Lock()
188
+ with lock:
189
+ result = incremental_update(repo_root, on_disk, deleted, store)
190
+ if result["files_updated"] or result["files_removed"]:
191
+ _log.info(
192
+ "Catch-up sync: updated %d, removed %d (%.3fs)",
193
+ result["files_updated"],
194
+ result["files_removed"],
195
+ result["elapsed_seconds"],
196
+ )
197
+ else:
198
+ _log.info("Catch-up sync: index already current")
199
+ return result
200
+
201
+
202
+ def _catch_up_in_background(
203
+ repo_root: Path, store: GraphStore, settings: Settings, handler: _DebouncedHandler,
204
+ ) -> None:
205
+ # NOTE: deliberately no sys.stdout redirect here — this thread runs
206
+ # concurrently with the MCP initialize handshake on the real stdout;
207
+ # swapping the global stdout would corrupt the JSON-RPC stream.
208
+ # Parsers do not print, so there is nothing to redirect anyway.
209
+ try:
210
+ catch_up_sync(repo_root, store, settings, db_lock=handler.db_lock)
211
+ except Exception as exc:
212
+ _log.warning("Catch-up sync failed: %s", exc)
213
+
214
+
150
215
  def start_watching_background(
151
216
  repo_root: Path, db_path: Path,
152
217
  ) -> tuple[Observer, GraphStore, _DebouncedHandler]:
@@ -166,7 +231,15 @@ def start_watching_background(
166
231
  observer = Observer()
167
232
  observer.daemon = True
168
233
  observer.schedule(handler, str(repo_root), recursive=True)
234
+ # Observer starts BEFORE the catch-up scan so no event falls in the gap;
235
+ # the hash check in incremental_update makes any overlap harmless.
169
236
  observer.start()
237
+ threading.Thread(
238
+ target=_catch_up_in_background,
239
+ args=(repo_root, store, settings, handler),
240
+ name="cb-catchup-sync",
241
+ daemon=True,
242
+ ).start()
170
243
  _log.info("Background watcher started for %s", repo_root)
171
244
  return observer, store, handler
172
245
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebrain
3
- Version: 0.3.4
3
+ Version: 0.3.5
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.3.4"
7
+ version = "0.3.5"
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"}
@@ -131,9 +131,10 @@ def test_find_watch_target_falls_back_when_no_host_match(monkeypatch):
131
131
  return {10: great, 11: grand, 12: parent}[pid]
132
132
 
133
133
  monkeypatch.setattr(psutil, "Process", fake_process)
134
- pid, ctime = ml._find_watch_target(12)
134
+ pid, ctime, anchored = ml._find_watch_target(12)
135
135
  assert pid == 12
136
136
  assert ctime == 300.0
137
+ assert anchored is False
137
138
 
138
139
 
139
140
  def test_find_watch_target_walks_to_claude(monkeypatch):
@@ -155,9 +156,10 @@ def test_find_watch_target_walks_to_claude(monkeypatch):
155
156
  py = _Proc(300, "python.exe", parent=launcher, ctime=3000.0)
156
157
 
157
158
  monkeypatch.setattr(psutil, "Process", lambda pid: {100: claude, 200: launcher, 300: py}[pid])
158
- pid, ctime = ml._find_watch_target(300)
159
+ pid, ctime, anchored = ml._find_watch_target(300)
159
160
  assert pid == 100
160
161
  assert ctime == 1000.0
162
+ assert anchored is True
161
163
 
162
164
 
163
165
  def test_find_watch_target_handles_no_psutil(monkeypatch):
@@ -171,9 +173,32 @@ def test_find_watch_target_handles_no_psutil(monkeypatch):
171
173
  return real_import(name, *a, **k)
172
174
 
173
175
  monkeypatch.setattr(builtins, "__import__", blocked)
174
- pid, ctime = ml._find_watch_target(42)
176
+ pid, ctime, anchored = ml._find_watch_target(42)
175
177
  assert pid == 42
176
178
  assert ctime is None
179
+ assert anchored is False
180
+
181
+
182
+ def test_effective_idle_timeout_disabled_when_host_anchored(monkeypatch):
183
+ """Anchored to a real IDE host → parent watchdog owns the lifecycle;
184
+ the idle backstop must NOT kill the server (and its file watcher)."""
185
+ monkeypatch.delenv("CODEBRAIN_MCP_IDLE_TIMEOUT", raising=False)
186
+ assert ml._effective_idle_timeout(True) == 0
187
+
188
+
189
+ def test_effective_idle_timeout_backstop_when_not_anchored(monkeypatch):
190
+ """No host found (psutil missing / odd process tree) → keep the 30-min
191
+ backstop so a truly orphaned server cannot hold the DB forever."""
192
+ monkeypatch.delenv("CODEBRAIN_MCP_IDLE_TIMEOUT", raising=False)
193
+ assert ml._effective_idle_timeout(False) == ml.IDLE_TIMEOUT_SECONDS
194
+
195
+
196
+ def test_effective_idle_timeout_env_override_wins(monkeypatch):
197
+ """An explicit CODEBRAIN_MCP_IDLE_TIMEOUT always applies, even anchored."""
198
+ monkeypatch.setenv("CODEBRAIN_MCP_IDLE_TIMEOUT", "120")
199
+ monkeypatch.setattr(ml, "IDLE_TIMEOUT_SECONDS", 120)
200
+ assert ml._effective_idle_timeout(True) == 120
201
+ assert ml._effective_idle_timeout(False) == 120
177
202
 
178
203
 
179
204
  def test_install_watchdogs_is_idempotent(tmp_path, monkeypatch):
@@ -0,0 +1,244 @@
1
+ """Tests for the file watcher debounced handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import pytest
11
+
12
+
13
+ class MockEvent:
14
+ """Minimal stand-in for watchdog FileSystemEvent."""
15
+
16
+ def __init__(self, src_path: str, is_directory: bool = False, dest_path: str = ""):
17
+ self.src_path = src_path
18
+ self.dest_path = dest_path
19
+ self.is_directory = is_directory
20
+
21
+
22
+ class TestDebouncedHandler:
23
+ def _make_handler(self, repo_root, store, debounce=0.05):
24
+ from codebrain.watcher.file_watcher import _DebouncedHandler
25
+ return _DebouncedHandler(
26
+ repo_root=repo_root,
27
+ store=store,
28
+ debounce=debounce,
29
+ extensions=frozenset({".py", ".ts"}),
30
+ )
31
+
32
+ def test_on_modified_collects_path(self, tmp_path):
33
+ store = MagicMock()
34
+ handler = self._make_handler(tmp_path, store)
35
+ event = MockEvent(str(tmp_path / "foo.py"))
36
+ handler.on_modified(event)
37
+ assert Path(event.src_path) in handler._changed
38
+
39
+ def test_ignores_irrelevant_extensions(self, tmp_path):
40
+ store = MagicMock()
41
+ handler = self._make_handler(tmp_path, store)
42
+ event = MockEvent(str(tmp_path / "readme.md"))
43
+ handler.on_modified(event)
44
+ assert len(handler._changed) == 0
45
+
46
+ def test_ignores_directories(self, tmp_path):
47
+ store = MagicMock()
48
+ handler = self._make_handler(tmp_path, store)
49
+ event = MockEvent(str(tmp_path / "subdir"), is_directory=True)
50
+ handler.on_modified(event)
51
+ assert len(handler._changed) == 0
52
+
53
+ def test_on_created_collects_path(self, tmp_path):
54
+ store = MagicMock()
55
+ handler = self._make_handler(tmp_path, store)
56
+ event = MockEvent(str(tmp_path / "new.py"))
57
+ handler.on_created(event)
58
+ assert Path(event.src_path) in handler._changed
59
+
60
+ def test_on_deleted_collects_path(self, tmp_path):
61
+ store = MagicMock()
62
+ handler = self._make_handler(tmp_path, store)
63
+ event = MockEvent(str(tmp_path / "old.py"))
64
+ handler.on_deleted(event)
65
+ assert Path(event.src_path) in handler._deleted
66
+
67
+ def test_deleted_removes_from_changed(self, tmp_path):
68
+ store = MagicMock()
69
+ handler = self._make_handler(tmp_path, store)
70
+ path = str(tmp_path / "old.py")
71
+ handler.on_modified(MockEvent(path))
72
+ assert Path(path) in handler._changed
73
+ handler.on_deleted(MockEvent(path))
74
+ assert Path(path) not in handler._changed
75
+ assert Path(path) in handler._deleted
76
+
77
+ def test_on_moved_tracks_both(self, tmp_path):
78
+ store = MagicMock()
79
+ handler = self._make_handler(tmp_path, store)
80
+ event = MockEvent(
81
+ str(tmp_path / "old.py"),
82
+ dest_path=str(tmp_path / "new.py"),
83
+ )
84
+ handler.on_moved(event)
85
+ assert Path(event.src_path) in handler._deleted
86
+ assert Path(event.dest_path) in handler._changed
87
+
88
+ @patch("codebrain.watcher.file_watcher.incremental_update")
89
+ def test_flush_calls_incremental_update(self, mock_update, tmp_path):
90
+ mock_update.return_value = {
91
+ "files_updated": 1,
92
+ "files_removed": 0,
93
+ "errors": [],
94
+ "elapsed_seconds": 0.001,
95
+ }
96
+ store = MagicMock()
97
+ handler = self._make_handler(tmp_path, store, debounce=0.01)
98
+ handler.on_modified(MockEvent(str(tmp_path / "foo.py")))
99
+ # Wait for debounce to fire
100
+ time.sleep(0.1)
101
+ assert mock_update.called
102
+
103
+ @patch("codebrain.watcher.file_watcher.incremental_update")
104
+ def test_debounce_collapses_events(self, mock_update, tmp_path):
105
+ mock_update.return_value = {
106
+ "files_updated": 1,
107
+ "files_removed": 0,
108
+ "errors": [],
109
+ "elapsed_seconds": 0.001,
110
+ }
111
+ store = MagicMock()
112
+ # Deterministic version: a huge debounce so no timer ever fires on
113
+ # its own, then flush manually. Timing-based variants flake on a
114
+ # loaded machine — Timer.cancel() loses the race against a timer
115
+ # that already started, producing a legitimate second flush.
116
+ handler = self._make_handler(tmp_path, store, debounce=300)
117
+ # Rapid-fire multiple events
118
+ for i in range(5):
119
+ handler.on_modified(MockEvent(str(tmp_path / "foo.py")))
120
+ # All 5 events collapsed into a single pending change
121
+ assert handler._changed == {Path(str(tmp_path / "foo.py"))}
122
+ handler._timer.cancel()
123
+ handler._flush()
124
+ # Should only flush once
125
+ assert mock_update.call_count == 1
126
+ # Sets drained — a follow-up flush with nothing pending is a no-op
127
+ handler._flush()
128
+ assert mock_update.call_count == 1
129
+
130
+ @patch("codebrain.watcher.file_watcher.incremental_update")
131
+ def test_flush_clears_sets(self, mock_update, tmp_path):
132
+ mock_update.return_value = {
133
+ "files_updated": 1,
134
+ "files_removed": 0,
135
+ "errors": [],
136
+ "elapsed_seconds": 0.001,
137
+ }
138
+ store = MagicMock()
139
+ handler = self._make_handler(tmp_path, store, debounce=0.01)
140
+ handler.on_modified(MockEvent(str(tmp_path / "foo.py")))
141
+ handler.on_deleted(MockEvent(str(tmp_path / "bar.py")))
142
+ time.sleep(0.1)
143
+ assert len(handler._changed) == 0
144
+ assert len(handler._deleted) == 0
145
+
146
+ @patch("codebrain.watcher.file_watcher.incremental_update")
147
+ def test_flush_marks_idle_activity(self, mock_update, tmp_path):
148
+ """A watcher flush must reset the MCP idle clock — otherwise the
149
+ idle watchdog kills the server while the user is actively editing."""
150
+ mock_update.return_value = {
151
+ "files_updated": 1,
152
+ "files_removed": 0,
153
+ "errors": [],
154
+ "elapsed_seconds": 0.001,
155
+ }
156
+ import codebrain.mcp_lifecycle as ml
157
+ before = ml._last_activity
158
+ time.sleep(0.02)
159
+ store = MagicMock()
160
+ handler = self._make_handler(tmp_path, store, debounce=0.01)
161
+ handler.on_modified(MockEvent(str(tmp_path / "foo.py")))
162
+ time.sleep(0.1)
163
+ assert ml._last_activity > before
164
+
165
+
166
+ class TestCatchUpSync:
167
+ """Changes made while no watcher was alive must be picked up at startup."""
168
+
169
+ def _make_repo(self, tmp_path):
170
+ repo = tmp_path / "repo"
171
+ repo.mkdir()
172
+ (repo / "alpha.py").write_text("def alpha():\n return 1\n")
173
+ (repo / "beta.py").write_text("def beta():\n return 2\n")
174
+ from codebrain.indexer import full_index
175
+ full_index(repo)
176
+ return repo, repo / ".codebrain" / "graph.db"
177
+
178
+ def _function_names(self, store):
179
+ rows = store.conn.execute(
180
+ "SELECT name FROM nodes WHERE type = 'function'"
181
+ ).fetchall()
182
+ return {row["name"] for row in rows}
183
+
184
+ def test_picks_up_offline_modification(self, tmp_path):
185
+ from codebrain.graph.store import GraphStore
186
+ from codebrain.watcher.file_watcher import catch_up_sync
187
+ repo, db = self._make_repo(tmp_path)
188
+ # Simulate an edit while no watcher process was running
189
+ (repo / "alpha.py").write_text("def alpha_renamed():\n return 1\n")
190
+ with GraphStore(db) as store:
191
+ result = catch_up_sync(repo, store)
192
+ assert result["files_updated"] == 1
193
+ names = self._function_names(store)
194
+ assert "alpha_renamed" in names
195
+ assert "alpha" not in names
196
+
197
+ def test_picks_up_offline_new_file(self, tmp_path):
198
+ from codebrain.graph.store import GraphStore
199
+ from codebrain.watcher.file_watcher import catch_up_sync
200
+ repo, db = self._make_repo(tmp_path)
201
+ (repo / "gamma.py").write_text("def gamma():\n return 3\n")
202
+ with GraphStore(db) as store:
203
+ result = catch_up_sync(repo, store)
204
+ assert result["files_updated"] == 1
205
+ assert "gamma" in self._function_names(store)
206
+
207
+ def test_picks_up_offline_deletion(self, tmp_path):
208
+ from codebrain.graph.store import GraphStore
209
+ from codebrain.watcher.file_watcher import catch_up_sync
210
+ repo, db = self._make_repo(tmp_path)
211
+ (repo / "beta.py").unlink()
212
+ with GraphStore(db) as store:
213
+ result = catch_up_sync(repo, store)
214
+ assert result["files_removed"] == 1
215
+ assert "beta" not in self._function_names(store)
216
+ assert "beta.py" not in store.all_file_paths()
217
+
218
+ def test_noop_when_index_current(self, tmp_path):
219
+ from codebrain.graph.store import GraphStore
220
+ from codebrain.watcher.file_watcher import catch_up_sync
221
+ repo, db = self._make_repo(tmp_path)
222
+ with GraphStore(db) as store:
223
+ result = catch_up_sync(repo, store)
224
+ assert result["files_updated"] == 0
225
+ assert result["files_removed"] == 0
226
+
227
+ def test_start_watching_background_runs_catch_up(self, tmp_path):
228
+ """The MCP startup path must self-heal a stale index automatically."""
229
+ from codebrain.watcher.file_watcher import start_watching_background
230
+ repo, db = self._make_repo(tmp_path)
231
+ # Offline edit: rename a symbol while no watcher exists
232
+ (repo / "alpha.py").write_text("def alpha_v2():\n return 1\n")
233
+ observer, store, _handler = start_watching_background(repo, db)
234
+ try:
235
+ deadline = time.time() + 10
236
+ while time.time() < deadline:
237
+ if "alpha_v2" in self._function_names(store):
238
+ break
239
+ time.sleep(0.1)
240
+ assert "alpha_v2" in self._function_names(store)
241
+ finally:
242
+ observer.stop()
243
+ observer.join(timeout=5)
244
+ store.close()