codebrain 0.3.3__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.
- {codebrain-0.3.3 → codebrain-0.3.5}/PKG-INFO +1 -1
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/__init__.py +1 -1
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/cli.py +1 -1
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/graph/store.py +5 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/mcp_lifecycle.py +41 -14
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/watcher/file_watcher.py +76 -3
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain.egg-info/PKG-INFO +1 -1
- {codebrain-0.3.3 → codebrain-0.3.5}/pyproject.toml +1 -1
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_cli.py +30 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_mcp_lifecycle.py +28 -3
- codebrain-0.3.5/tests/test_watcher.py +244 -0
- codebrain-0.3.3/tests/test_watcher.py +0 -135
- {codebrain-0.3.3 → codebrain-0.3.5}/LICENSE +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/README.md +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/__main__.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/actions/__init__.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/actions/base.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/actions/refactor.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/actions/reviewer.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/actions/test_gen.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/agent_bridge.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/analyzer.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/api.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/api_models.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/architecture.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/comprehension.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/config.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/context.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/cross_query.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/cross_registry.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/diff_impact.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/env_migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/equivalence.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/export.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/frontend.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/graph/__init__.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/graph/query.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/graph/schema.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/hook_runner.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/hooks.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/indexer.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/kt.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/kt_video.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/llm.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/logging.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/mcp_server.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/memory/__init__.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/memory/store.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/modernize.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/onboard.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/__init__.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/base.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/cobol_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/config_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/csharp_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/dart_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/fortran_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/frontend_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/go_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/java_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/kotlin_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/models.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/mumps_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/plsql_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/python_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/registry.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/rust_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/schema_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/typescript_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/typescript_treesitter.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/parser/vue_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/py.typed +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/resolver.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/rewriter.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/schema_migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/settings.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/susa_auth.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/test_gaps.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/test_runner.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/tour.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/ui_migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/utils.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/validator.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain/watcher/__init__.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain.egg-info/SOURCES.txt +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain.egg-info/dependency_links.txt +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain.egg-info/entry_points.txt +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain.egg-info/requires.txt +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/codebrain.egg-info/top_level.txt +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/setup.cfg +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_actions.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_agent_bridge.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_analyzer.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_api.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_architecture.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_ci.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_comprehension.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_context.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_contracts_real.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_coverage_gaps.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_cross_repo.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_csharp_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_dart_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_dataflow.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_dead_code_confidence.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_diff_impact.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_env_migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_equivalence.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_error_recovery.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_export.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_fingerprints.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_frontend.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_gate_battle.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_go_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_hooks.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_indexer.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_infra_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_install.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_integration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_java_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_jyotishyamitra.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_kotlin_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_kt.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_legacy_parsers.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_llm.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_mcp_server.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_memory.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_modernize.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_multi_project_cli.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_narratives.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_onboard.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_orm_detection.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_output_quality.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_plugin_system.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_production_hardening.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_query.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_real_codebase.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_real_features.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_real_frontend.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_real_repos.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_real_world.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_resolver.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_rewriter.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_rust_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_scale.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_scale_optimizations.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_scale_real.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_schema.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_schema_migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_schema_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_settings.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_store.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_test_runner.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_tour.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_translate.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_ts_ast_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_ts_parser_enhanced.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_typescript_parser.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_ui_migration.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_utils.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_validation_narratives.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_validator.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_validator_scenarios.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_vscode_extension.py +0 -0
- {codebrain-0.3.3 → codebrain-0.3.5}/tests/test_watch_validate.py +0 -0
- {codebrain-0.3.3 → 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.
|
|
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
|
|
@@ -375,7 +375,7 @@ def setup(ctx: click.Context, force: bool) -> None:
|
|
|
375
375
|
project_name = repo_root.name
|
|
376
376
|
|
|
377
377
|
# 1. Index if needed
|
|
378
|
-
db_path =
|
|
378
|
+
db_path = _db_path(repo_root)
|
|
379
379
|
if not db_path.exists():
|
|
380
380
|
files = discover_files(repo_root)
|
|
381
381
|
click.echo(f"Indexing {repo_root} ({len(files)} files) ...")
|
|
@@ -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
|
|
245
|
-
|
|
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,
|
|
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 >
|
|
253
|
-
_exit(f"idle for {idle:.0f}s (limit {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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"}
|
|
@@ -39,6 +39,36 @@ class TestInit:
|
|
|
39
39
|
assert "Edges:" in result.output
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class TestSetup:
|
|
43
|
+
def test_setup_cold(self, simple_project):
|
|
44
|
+
runner = CliRunner()
|
|
45
|
+
result = _invoke(runner, ["setup"], str(simple_project))
|
|
46
|
+
assert result.exit_code == 0, result.output
|
|
47
|
+
assert (simple_project / "CLAUDE.md").exists()
|
|
48
|
+
config = json.loads((simple_project / ".mcp.json").read_text())
|
|
49
|
+
assert "codebrain" in config["mcpServers"]
|
|
50
|
+
assert config["mcpServers"]["codebrain"]["args"] == ["-m", "codebrain.mcp_server"]
|
|
51
|
+
|
|
52
|
+
def test_setup_preserves_existing_mcp_servers(self, simple_project):
|
|
53
|
+
existing = {"mcpServers": {"other": {"command": "node", "args": ["server.js"]}}}
|
|
54
|
+
(simple_project / ".mcp.json").write_text(json.dumps(existing))
|
|
55
|
+
runner = CliRunner()
|
|
56
|
+
result = _invoke(runner, ["setup"], str(simple_project))
|
|
57
|
+
assert result.exit_code == 0, result.output
|
|
58
|
+
config = json.loads((simple_project / ".mcp.json").read_text())
|
|
59
|
+
assert "other" in config["mcpServers"]
|
|
60
|
+
assert "codebrain" in config["mcpServers"]
|
|
61
|
+
|
|
62
|
+
def test_setup_idempotent_without_force(self, simple_project):
|
|
63
|
+
runner = CliRunner()
|
|
64
|
+
first = _invoke(runner, ["setup"], str(simple_project))
|
|
65
|
+
assert first.exit_code == 0
|
|
66
|
+
(simple_project / "CLAUDE.md").write_text("custom user content")
|
|
67
|
+
second = _invoke(runner, ["setup"], str(simple_project))
|
|
68
|
+
assert second.exit_code == 0
|
|
69
|
+
assert (simple_project / "CLAUDE.md").read_text() == "custom user content"
|
|
70
|
+
|
|
71
|
+
|
|
42
72
|
class TestStatus:
|
|
43
73
|
def test_status_text(self, indexed_project):
|
|
44
74
|
repo_root, store = indexed_project
|
|
@@ -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()
|