codebrain 0.3.5__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.3.5 → codebrain-0.4.1}/PKG-INFO +1 -1
  2. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/__init__.py +1 -1
  3. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/cli.py +43 -24
  4. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/store.py +26 -5
  5. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/indexer.py +56 -7
  6. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/mcp_lifecycle.py +49 -24
  7. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/mcp_server.py +109 -0
  8. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/PKG-INFO +1 -1
  9. {codebrain-0.3.5 → codebrain-0.4.1}/pyproject.toml +1 -1
  10. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_cli.py +19 -0
  11. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_indexer.py +54 -0
  12. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_mcp_lifecycle.py +61 -11
  13. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_mcp_server.py +114 -0
  14. {codebrain-0.3.5 → codebrain-0.4.1}/LICENSE +0 -0
  15. {codebrain-0.3.5 → codebrain-0.4.1}/README.md +0 -0
  16. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/__main__.py +0 -0
  17. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/__init__.py +0 -0
  18. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/base.py +0 -0
  19. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/refactor.py +0 -0
  20. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/reviewer.py +0 -0
  21. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/test_gen.py +0 -0
  22. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/agent_bridge.py +0 -0
  23. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/analyzer.py +0 -0
  24. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/api.py +0 -0
  25. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/api_models.py +0 -0
  26. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/architecture.py +0 -0
  27. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/comprehension.py +0 -0
  28. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/config.py +0 -0
  29. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/context.py +0 -0
  30. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/cross_query.py +0 -0
  31. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/cross_registry.py +0 -0
  32. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/diff_impact.py +0 -0
  33. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/env_migration.py +0 -0
  34. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/equivalence.py +0 -0
  35. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/export.py +0 -0
  36. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/frontend.py +0 -0
  37. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/__init__.py +0 -0
  38. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/query.py +0 -0
  39. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/schema.py +0 -0
  40. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/hook_runner.py +0 -0
  41. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/hooks.py +0 -0
  42. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/kt.py +0 -0
  43. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/kt_video.py +0 -0
  44. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/llm.py +0 -0
  45. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/logging.py +0 -0
  46. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/memory/__init__.py +0 -0
  47. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/memory/store.py +0 -0
  48. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/migration.py +0 -0
  49. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/modernize.py +0 -0
  50. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/onboard.py +0 -0
  51. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/__init__.py +0 -0
  52. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/base.py +0 -0
  53. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/cobol_parser.py +0 -0
  54. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/config_parser.py +0 -0
  55. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/csharp_parser.py +0 -0
  56. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/dart_parser.py +0 -0
  57. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/fortran_parser.py +0 -0
  58. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/frontend_parser.py +0 -0
  59. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/go_parser.py +0 -0
  60. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/java_parser.py +0 -0
  61. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/kotlin_parser.py +0 -0
  62. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/models.py +0 -0
  63. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/mumps_parser.py +0 -0
  64. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/plsql_parser.py +0 -0
  65. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/python_parser.py +0 -0
  66. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/registry.py +0 -0
  67. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/rust_parser.py +0 -0
  68. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/schema_parser.py +0 -0
  69. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/typescript_parser.py +0 -0
  70. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/typescript_treesitter.py +0 -0
  71. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/vue_parser.py +0 -0
  72. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/py.typed +0 -0
  73. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/resolver.py +0 -0
  74. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/rewriter.py +0 -0
  75. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/schema_migration.py +0 -0
  76. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/settings.py +0 -0
  77. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/susa_auth.py +0 -0
  78. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/test_gaps.py +0 -0
  79. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/test_runner.py +0 -0
  80. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/tour.py +0 -0
  81. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/ui_migration.py +0 -0
  82. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/utils.py +0 -0
  83. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/validator.py +0 -0
  84. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/watcher/__init__.py +0 -0
  85. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/watcher/file_watcher.py +0 -0
  86. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/SOURCES.txt +0 -0
  87. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/dependency_links.txt +0 -0
  88. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/entry_points.txt +0 -0
  89. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/requires.txt +0 -0
  90. {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/top_level.txt +0 -0
  91. {codebrain-0.3.5 → codebrain-0.4.1}/setup.cfg +0 -0
  92. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_actions.py +0 -0
  93. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_agent_bridge.py +0 -0
  94. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_analyzer.py +0 -0
  95. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_api.py +0 -0
  96. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_architecture.py +0 -0
  97. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ci.py +0 -0
  98. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_comprehension.py +0 -0
  99. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_context.py +0 -0
  100. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_contracts_real.py +0 -0
  101. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_coverage_gaps.py +0 -0
  102. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_cross_repo.py +0 -0
  103. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_csharp_parser.py +0 -0
  104. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_dart_parser.py +0 -0
  105. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_dataflow.py +0 -0
  106. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_dead_code_confidence.py +0 -0
  107. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_diff_impact.py +0 -0
  108. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_env_migration.py +0 -0
  109. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_equivalence.py +0 -0
  110. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_error_recovery.py +0 -0
  111. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_export.py +0 -0
  112. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_fingerprints.py +0 -0
  113. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_frontend.py +0 -0
  114. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_gate_battle.py +0 -0
  115. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_go_parser.py +0 -0
  116. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_hooks.py +0 -0
  117. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_infra_parser.py +0 -0
  118. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_install.py +0 -0
  119. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_integration.py +0 -0
  120. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_java_parser.py +0 -0
  121. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_jyotishyamitra.py +0 -0
  122. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_kotlin_parser.py +0 -0
  123. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_kt.py +0 -0
  124. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_legacy_parsers.py +0 -0
  125. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_llm.py +0 -0
  126. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_memory.py +0 -0
  127. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_migration.py +0 -0
  128. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_modernize.py +0 -0
  129. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_multi_project_cli.py +0 -0
  130. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_narratives.py +0 -0
  131. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_onboard.py +0 -0
  132. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_orm_detection.py +0 -0
  133. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_output_quality.py +0 -0
  134. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_parser.py +0 -0
  135. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_plugin_system.py +0 -0
  136. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_production_hardening.py +0 -0
  137. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_query.py +0 -0
  138. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_codebase.py +0 -0
  139. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_features.py +0 -0
  140. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_frontend.py +0 -0
  141. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_repos.py +0 -0
  142. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_world.py +0 -0
  143. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_resolver.py +0 -0
  144. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_rewriter.py +0 -0
  145. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_rust_parser.py +0 -0
  146. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_scale.py +0 -0
  147. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_scale_optimizations.py +0 -0
  148. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_scale_real.py +0 -0
  149. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_schema.py +0 -0
  150. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_schema_migration.py +0 -0
  151. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_schema_parser.py +0 -0
  152. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_settings.py +0 -0
  153. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_store.py +0 -0
  154. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_test_runner.py +0 -0
  155. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_tour.py +0 -0
  156. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_translate.py +0 -0
  157. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ts_ast_parser.py +0 -0
  158. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ts_parser_enhanced.py +0 -0
  159. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_typescript_parser.py +0 -0
  160. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ui_migration.py +0 -0
  161. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_utils.py +0 -0
  162. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_validation_narratives.py +0 -0
  163. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_validator.py +0 -0
  164. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_validator_scenarios.py +0 -0
  165. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_vscode_extension.py +0 -0
  166. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_watch_validate.py +0 -0
  167. {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_watcher.py +0 -0
  168. {codebrain-0.3.5 → 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.3.5
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.3.5"
10
+ __version__ = "0.4.1"
@@ -58,9 +58,17 @@ def _require_index(repo_root: Path) -> Path:
58
58
  import gc
59
59
  gc.collect() # Release any lingering connections
60
60
  try:
61
- db.unlink(missing_ok=True)
62
- for wal in db.parent.glob(f"{db.name}-*"):
63
- wal.unlink(missing_ok=True)
61
+ # Prefer rebuilding in place — deleting the file fails on
62
+ # Windows while any MCP server holds it open. Only fall back
63
+ # to file replacement when the DB is unreadable.
64
+ try:
65
+ from codebrain.graph.store import GraphStore
66
+ with GraphStore(db) as _store:
67
+ _store.clear_all()
68
+ except Exception:
69
+ db.unlink(missing_ok=True)
70
+ for wal in db.parent.glob(f"{db.name}-*"):
71
+ wal.unlink(missing_ok=True)
64
72
  from codebrain.indexer import full_index
65
73
  full_index(repo_root, db)
66
74
  click.echo(click.style("Database rebuilt successfully.", fg="green"), err=True)
@@ -1618,33 +1626,37 @@ def reindex(ctx: click.Context, yes: bool, as_json: bool) -> None:
1618
1626
  db = _db_path(repo_root)
1619
1627
 
1620
1628
  if not yes and not as_json:
1621
- if not click.confirm("This will delete and rebuild the entire index. Continue?"):
1629
+ if not click.confirm("This will rebuild the entire index. Continue?"):
1622
1630
  click.echo("Aborted.")
1623
1631
  return
1624
1632
 
1633
+ # Rebuild IN PLACE (clear tables, re-fill) rather than deleting the DB
1634
+ # file. Deleting fails on Windows whenever any MCP server holds the DB
1635
+ # open — and with session-long server lifetimes that is "always". WAL
1636
+ # mode makes the in-place rebuild safe with readers attached, so reindex
1637
+ # never needs to find, kill, or wait for other processes.
1625
1638
  if db.exists():
1639
+ from codebrain.graph.store import GraphStore
1626
1640
  try:
1627
- db.unlink()
1628
- except PermissionError:
1629
- from codebrain.mcp_lifecycle import find_db_lock_holder, kill_pid
1630
- holder = find_db_lock_holder(repo_root)
1631
- if holder is None:
1632
- click.echo(
1633
- " Database is locked but no recorded MCP holder.\n"
1634
- " Find the holder with `Get-Process python` (Windows) or `lsof` (Unix) and stop it."
1635
- )
1641
+ with GraphStore(db) as _store:
1642
+ _store.clear_all()
1643
+ except Exception as exc:
1644
+ # Corrupted beyond clearing — replacing the file is the only
1645
+ # option left. This fails if another process holds it open.
1646
+ click.echo(click.style(
1647
+ f" Index unreadable ({exc}); replacing the database file.", fg="yellow",
1648
+ ))
1649
+ try:
1650
+ db.unlink()
1651
+ for wal in db.parent.glob(f"{db.name}-*"):
1652
+ wal.unlink(missing_ok=True)
1653
+ except PermissionError:
1654
+ click.echo(click.style(
1655
+ " The corrupted database is held open by a running MCP server.\n"
1656
+ " Close Claude Code sessions for this project (or run "
1657
+ "`brain doctor --kill-stale-mcps`) and retry.", fg="red",
1658
+ ))
1636
1659
  raise
1637
- should_kill = yes or as_json or click.confirm(
1638
- f"Database locked by CodeBrain MCP server PID {holder}. Terminate it and continue?"
1639
- )
1640
- if not should_kill:
1641
- click.echo("Aborted.")
1642
- return
1643
- status = kill_pid(holder)
1644
- click.echo(f" Killed MCP PID {holder}: {status}")
1645
- import time as _time
1646
- _time.sleep(1)
1647
- db.unlink()
1648
1660
 
1649
1661
  files = discover_files(repo_root)
1650
1662
 
@@ -1847,6 +1859,13 @@ def repair(ctx: click.Context) -> None:
1847
1859
  break
1848
1860
  except PermissionError:
1849
1861
  _time.sleep(0.2)
1862
+ else:
1863
+ click.echo(click.style(
1864
+ "Could not replace the corrupted database — a running MCP server "
1865
+ "holds it open.\nClose Claude Code sessions for this project (or "
1866
+ "run `brain doctor --kill-stale-mcps`) and retry.", fg="red",
1867
+ ))
1868
+ sys.exit(1)
1850
1869
  # Also clean up WAL/SHM files
1851
1870
  for suffix in ("-wal", "-shm"):
1852
1871
  wal = db.parent / (db.name + suffix)
@@ -22,11 +22,18 @@ class GraphStore:
22
22
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
23
23
  _log.debug("Opening database %s", self.db_path)
24
24
  self.conn = sqlite3.connect(str(self.db_path), timeout=30, check_same_thread=False)
25
- self.conn.row_factory = sqlite3.Row
26
- self.conn.execute("PRAGMA journal_mode=WAL")
27
- self.conn.execute("PRAGMA synchronous=NORMAL")
28
- self.conn.execute("PRAGMA foreign_keys=OFF")
29
- migrate_db(self.conn)
25
+ try:
26
+ self.conn.row_factory = sqlite3.Row
27
+ self.conn.execute("PRAGMA journal_mode=WAL")
28
+ self.conn.execute("PRAGMA synchronous=NORMAL")
29
+ self.conn.execute("PRAGMA foreign_keys=OFF")
30
+ migrate_db(self.conn)
31
+ except Exception:
32
+ # A corrupted/garbage file makes the pragmas raise AFTER the OS
33
+ # handle is open. Without this close, the dangling handle keeps
34
+ # the file locked on Windows and recovery-by-replacement fails.
35
+ self.conn.close()
36
+ raise
30
37
 
31
38
  def close(self) -> None:
32
39
  _log.debug("Closing database %s", self.db_path)
@@ -95,6 +102,20 @@ class GraphStore:
95
102
  self.conn.execute("DELETE FROM nodes WHERE file_path = ?", (path,))
96
103
  self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
97
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
+
98
119
  def clear_all(self) -> None:
99
120
  """Delete all data from the graph (files, nodes, edges).
100
121
 
@@ -490,6 +490,43 @@ def full_index(
490
490
  }
491
491
 
492
492
 
493
+ def scan_stale(
494
+ repo_root: Path,
495
+ store: GraphStore,
496
+ settings: "Settings | None" = None,
497
+ ) -> tuple[list[Path], list[Path]]:
498
+ """Cheap staleness scan: which files changed since they were indexed?
499
+
500
+ Returns ``(changed_candidates, deleted)``. Uses an mtime-vs-last_indexed
501
+ prefilter so no file contents are read here — false candidates are fine
502
+ because :func:`incremental_update` hash-checks before re-parsing. This is
503
+ the fast freshness layer (milliseconds on a warm FS cache); the exhaustive
504
+ hash-everything pass lives in the watcher's startup catch-up sync.
505
+ """
506
+ rows = store.conn.execute("SELECT path, last_indexed FROM files").fetchall()
507
+ stored = {row["path"]: row["last_indexed"] for row in rows}
508
+
509
+ changed: list[Path] = []
510
+ seen: set[str] = set()
511
+ # Files modified within this window before indexing finished may have
512
+ # raced the indexer — treat them as candidates (hash check disambiguates).
513
+ slack = 2.0
514
+ for file_path in discover_files(repo_root, settings):
515
+ rel = normalize_path(file_path, repo_root)
516
+ seen.add(rel)
517
+ last_indexed = stored.get(rel)
518
+ if last_indexed is None:
519
+ changed.append(file_path) # new file
520
+ continue
521
+ try:
522
+ if file_path.stat().st_mtime > last_indexed - slack:
523
+ changed.append(file_path)
524
+ except OSError:
525
+ continue
526
+ deleted = [repo_root / rel for rel in stored if rel not in seen]
527
+ return changed, deleted
528
+
529
+
493
530
  def incremental_update(
494
531
  repo_root: Path,
495
532
  changed_files: list[Path],
@@ -505,10 +542,15 @@ def incremental_update(
505
542
  removed = 0
506
543
  errors: list[str] = []
507
544
 
508
- for file_path in deleted_files:
509
- rel = normalize_path(file_path, repo_root)
510
- store.remove_file(rel)
511
- 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)
548
+
549
+ # tree-sitter can hang holding the GIL on Windows — isolate those
550
+ # extensions in a subprocess, same as full_index. A hang here would
551
+ # otherwise freeze the watcher/catch-up thread while it holds the DB
552
+ # lock, silently stopping auto-indexing for the rest of the session.
553
+ _NEEDS_ISOLATION = frozenset({".ts", ".tsx", ".js", ".jsx"})
512
554
 
513
555
  for file_path in changed_files:
514
556
  rel = normalize_path(file_path, repo_root)
@@ -519,9 +561,16 @@ def incremental_update(
519
561
  if current_hash == stored_hash:
520
562
  continue
521
563
 
522
- pf = _parse_file(file_path, repo_root)
523
- store.upsert_file(pf)
524
- updated += 1
564
+ if sys.platform == "win32" and file_path.suffix in _NEEDS_ISOLATION:
565
+ pf, err = _parse_with_timeout(file_path, repo_root, timeout=30)
566
+ if err:
567
+ errors.append(err)
568
+ continue
569
+ else:
570
+ pf = _parse_file(file_path, repo_root)
571
+ if pf is not None:
572
+ store.upsert_file(pf)
573
+ updated += 1
525
574
  except Exception as exc:
526
575
  errors.append(f"{rel}: {exc}")
527
576
 
@@ -79,39 +79,43 @@ def _read_pid_file(pid_file: Path) -> int | None:
79
79
  return None
80
80
 
81
81
 
82
- def _predecessor_has_live_host(pid: int) -> bool:
83
- """True if ``pid``'s ancestor chain contains a live host (claude/cursor/...).
84
-
85
- Such a predecessor belongs to a *concurrent* sibling Claude session and
86
- must not be terminated its disappearance would silently break that
87
- session's MCP. Without psutil we cannot tell, so be safe and assume yes.
82
+ def _predecessor_host_pid(pid: int) -> int | None:
83
+ """Return the PID of the live IDE host (claude/cursor/...) in ``pid``'s
84
+ ancestor chain, or None if there is none.
85
+
86
+ A predecessor with a live host belongs to *some* Claude session — but
87
+ whether it must be spared depends on WHICH session: a different host PID
88
+ means a concurrent sibling window (killing it would silently break that
89
+ session's MCP); the SAME host PID as ours means it is our own session's
90
+ orphaned predecessor left behind by a transport disconnect/reconnect,
91
+ and it must die or two servers fight over the SQLite DB.
88
92
  """
89
93
  try:
90
94
  import psutil
91
95
  except ImportError:
92
- return True
96
+ return None
93
97
  try:
94
98
  proc = psutil.Process(pid)
95
99
  except (psutil.NoSuchProcess, psutil.AccessDenied):
96
- return False
100
+ return None
97
101
  for _ in range(ANCESTOR_WALK_DEPTH):
98
102
  try:
99
103
  parent = proc.parent()
100
104
  except (psutil.NoSuchProcess, psutil.AccessDenied):
101
- return False
105
+ return None
102
106
  if parent is None:
103
- return False
107
+ return None
104
108
  try:
105
109
  name = (parent.name() or "").lower()
106
110
  except (psutil.NoSuchProcess, psutil.AccessDenied):
107
- return False
111
+ return None
108
112
  if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
109
- return True
113
+ return parent.pid
110
114
  proc = parent
111
- return False
115
+ return None
112
116
 
113
117
 
114
- def _kill_stale_predecessor(pid_file: Path) -> None:
118
+ def _kill_stale_predecessor(pid_file: Path, own_host_pid: int | None = None) -> None:
115
119
  if not pid_file.exists():
116
120
  return
117
121
  old_pid = _read_pid_file(pid_file)
@@ -128,12 +132,27 @@ def _kill_stale_predecessor(pid_file: Path) -> None:
128
132
  # PID was reused by an unrelated process. Don't touch.
129
133
  _log.debug("PID %d reused by unrelated process; leaving alone", old_pid)
130
134
  return
131
- if _predecessor_has_live_host(old_pid):
132
- # Concurrent sibling Claude session is still using this MCP leave it.
133
- # Without this, two Claude windows on the same repo race each other and
134
- # whichever started last kills the other's MCP.
135
- _log.debug("PID %d has a live IDE host; sibling MCP, leaving alone", old_pid)
135
+ pred_host = _predecessor_host_pid(old_pid)
136
+ if pred_host is not None and pred_host != own_host_pid:
137
+ # Live IDE host that is NOT ours concurrent sibling Claude session
138
+ # is still using this MCP leave it. Without this, two Claude
139
+ # windows on the same repo race each other and whichever started
140
+ # last kills the other's MCP.
141
+ _log.debug(
142
+ "PID %d has live IDE host %d (ours: %s); sibling MCP, leaving alone",
143
+ old_pid, pred_host, own_host_pid,
144
+ )
136
145
  return
146
+ if pred_host is not None:
147
+ # Same host as ours → our own session reconnected and left the old
148
+ # server behind with a dead stdio transport. It will never exit on
149
+ # its own (parent still alive, idle timer disabled when anchored)
150
+ # and holds the SQLite DB — kill it.
151
+ _log.warning(
152
+ "Predecessor PID %d shares our IDE host %d — duplicate from a "
153
+ "transport reconnect, terminating it",
154
+ old_pid, pred_host,
155
+ )
137
156
  try:
138
157
  proc = psutil.Process(old_pid)
139
158
  _log.warning("Killing stale CodeBrain MCP predecessor PID %d", old_pid)
@@ -300,17 +319,23 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
300
319
  start = time.time()
301
320
  mark_activity()
302
321
 
322
+ # Resolve our own IDE host FIRST — the predecessor check needs it to
323
+ # distinguish "our own session's orphan after a transport reconnect"
324
+ # (kill) from "a concurrent sibling window's server" (spare).
325
+ immediate_ppid = os.getppid()
326
+ initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
327
+ idle_timeout = _effective_idle_timeout(host_anchored)
328
+
303
329
  if repo_root is not None:
304
330
  from codebrain.config import CODEBRAIN_DIR
305
331
  pid_file = repo_root / CODEBRAIN_DIR / PID_FILE_NAME
306
- _kill_stale_predecessor(pid_file)
332
+ _kill_stale_predecessor(
333
+ pid_file,
334
+ own_host_pid=initial_ppid if host_anchored else None,
335
+ )
307
336
  _write_pid_file(pid_file)
308
337
  atexit.register(_remove_pid_file, pid_file)
309
338
 
310
- immediate_ppid = os.getppid()
311
- initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
312
- idle_timeout = _effective_idle_timeout(host_anchored)
313
-
314
339
  _log.info(
315
340
  "MCP watchdogs installed (ppid=%d via=%d, host_anchored=%s, "
316
341
  "idle_timeout=%ds, max_lifetime=%ds)",
@@ -110,6 +110,111 @@ def _make_store():
110
110
  return GraphStore(_find_db())
111
111
 
112
112
 
113
+ # ---------------------------------------------------------------------------
114
+ # Freshness-on-read
115
+ #
116
+ # Correctness must not depend on which background processes happen to be
117
+ # alive. The watcher (instant) and the startup catch-up sync (thorough) are
118
+ # optimizations; THIS is the guarantee: before serving a tool call, cheaply
119
+ # verify the index matches the working tree and sync the difference. Even if
120
+ # every watcher thread is dead, the answer the agent gets reflects current
121
+ # code.
122
+ # ---------------------------------------------------------------------------
123
+
124
+ import threading as _threading
125
+ import time as _time
126
+
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
+
132
+ _freshness_lock = _threading.Lock()
133
+ _last_freshness_check: dict[str, float] = {}
134
+
135
+ # Tools that must NOT trigger a freshness sync:
136
+ # - validators compare new/written content against the PRE-change graph;
137
+ # syncing first would erase the baseline and the structural gate would
138
+ # silently pass everything
139
+ # - reindex_codebase rebuilds anyway
140
+ # - project/memory tools don't read graph data
141
+ _FRESHNESS_EXEMPT_TOOLS = frozenset({
142
+ "validate_change",
143
+ "validate_changes",
144
+ "validate_after_write",
145
+ "propose_change",
146
+ "diff_impact",
147
+ "get_validation_status",
148
+ "reindex_codebase",
149
+ "set_project",
150
+ "get_project",
151
+ "save_memory",
152
+ "recall_memories",
153
+ "list_memories",
154
+ "update_memory",
155
+ "delete_memory",
156
+ })
157
+
158
+
159
+ def _ensure_fresh() -> None:
160
+ """Schedule a background freshness sync if one is due.
161
+
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).
167
+ """
168
+ if FRESHNESS_INTERVAL_SECONDS <= 0:
169
+ return
170
+ try:
171
+ db_path = _find_db()
172
+ except FileNotFoundError:
173
+ return
174
+ key = str(db_path)
175
+ now = _time.monotonic()
176
+ if now - _last_freshness_check.get(key, 0.0) < FRESHNESS_INTERVAL_SECONDS:
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.
182
+ if not _freshness_lock.acquire(blocking=False):
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."""
195
+ try:
196
+ from codebrain.graph.store import GraphStore
197
+ from codebrain.indexer import incremental_update, scan_stale
198
+
199
+ repo_root = db_path.parent.parent
200
+ with GraphStore(db_path) as store:
201
+ changed, deleted = scan_stale(repo_root, store)
202
+ if not changed and not deleted:
203
+ return
204
+ result = incremental_update(repo_root, changed, deleted, store)
205
+ if result["files_updated"] or result["files_removed"]:
206
+ _log.info(
207
+ "Freshness sync: updated %d, removed %d (%.3fs)",
208
+ result["files_updated"],
209
+ result["files_removed"],
210
+ result["elapsed_seconds"],
211
+ )
212
+ except Exception as exc:
213
+ _log.debug("Freshness sync skipped: %s", exc)
214
+ finally:
215
+ _freshness_lock.release()
216
+
217
+
113
218
  def _safe_tool(fn): # noqa: ANN001, ANN201
114
219
  """Wrap an MCP tool so it cooperates with MCP cancellation.
115
220
 
@@ -214,6 +319,10 @@ def _run_sync_protected(fn, args, kwargs): # noqa: ANN001, ANN202
214
319
  saved_stdout = sys.stdout
215
320
  sys.stdout = sys.stderr
216
321
  try:
322
+ # Freshness gate: sync index drift before answering. Skip for tools
323
+ # that rebuild or don't read the graph state being synced.
324
+ if fn.__name__ not in _FRESHNESS_EXEMPT_TOOLS:
325
+ _ensure_fresh()
217
326
  return fn(*args, **kwargs)
218
327
  finally:
219
328
  sys.stdout = saved_stdout
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebrain
3
- Version: 0.3.5
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.3.5"
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"}
@@ -411,6 +411,25 @@ class TestReindex:
411
411
  data = json.loads(result.output)
412
412
  assert "files_parsed" in data
413
413
 
414
+ def test_reindex_works_while_db_held_open(self, indexed_project):
415
+ """Reindex must succeed while an MCP server holds the DB open.
416
+
417
+ This is the everyday case: session-long MCP servers always have the
418
+ SQLite file open. The old implementation deleted the DB file, which
419
+ fails on Windows with WinError 32 whenever anyone is attached.
420
+ Rebuild must happen in place.
421
+ """
422
+ repo_root, store = indexed_project
423
+ # `store` stays OPEN — it plays the role of a running MCP server.
424
+ runner = CliRunner()
425
+ result = _invoke(runner, ["reindex", "--yes"], str(repo_root))
426
+ assert result.exit_code == 0, result.output
427
+ assert "Done" in result.output
428
+ # The still-attached holder sees the rebuilt data (WAL, same file)
429
+ count = store.conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
430
+ assert count > 0
431
+ store.close()
432
+
414
433
 
415
434
  class TestDoctor:
416
435
  def test_doctor_text(self, indexed_project):
@@ -113,3 +113,57 @@ class TestIncrementalUpdate:
113
113
  )
114
114
  assert result["files_updated"] == 0
115
115
  store.close()
116
+
117
+
118
+ class TestScanStale:
119
+ """Cheap mtime-based staleness scan used by freshness-on-read."""
120
+
121
+ def _indexed(self, simple_project):
122
+ db = simple_project / CODEBRAIN_DIR / DB_FILENAME
123
+ full_index(simple_project, db)
124
+ return GraphStore(db)
125
+
126
+ def test_clean_index_reports_nothing(self, simple_project: Path) -> None:
127
+ from codebrain.indexer import scan_stale
128
+ with self._indexed(simple_project) as store:
129
+ changed, deleted = scan_stale(simple_project, store)
130
+ # slack window may flag just-indexed files; hash check downstream
131
+ # makes that harmless — but nothing may be reported deleted.
132
+ assert deleted == []
133
+
134
+ def test_detects_modified_file(self, simple_project: Path) -> None:
135
+ import os, time
136
+ from codebrain.indexer import scan_stale
137
+ with self._indexed(simple_project) as store:
138
+ target = simple_project / "mypackage" / "core.py"
139
+ target.write_text(target.read_text() + "\n# touched\n")
140
+ future = time.time() + 10 # ensure mtime beats last_indexed + slack
141
+ os.utime(target, (future, future))
142
+ changed, _ = scan_stale(simple_project, store)
143
+ assert target in changed
144
+
145
+ def test_detects_new_file(self, simple_project: Path) -> None:
146
+ from codebrain.indexer import scan_stale
147
+ with self._indexed(simple_project) as store:
148
+ new = simple_project / "mypackage" / "fresh_module.py"
149
+ new.write_text("def fresh():\n return 42\n")
150
+ changed, _ = scan_stale(simple_project, store)
151
+ assert new in changed
152
+
153
+ def test_detects_deleted_file(self, simple_project: Path) -> None:
154
+ from codebrain.indexer import scan_stale
155
+ with self._indexed(simple_project) as store:
156
+ victim = simple_project / "mypackage" / "core.py"
157
+ victim.unlink()
158
+ _, deleted = scan_stale(simple_project, store)
159
+ assert victim in deleted
160
+
161
+ def test_untouched_old_file_not_candidate(self, simple_project: Path) -> None:
162
+ import os, time
163
+ from codebrain.indexer import scan_stale
164
+ with self._indexed(simple_project) as store:
165
+ target = simple_project / "mypackage" / "core.py"
166
+ past = time.time() - 3600 # well before indexing
167
+ os.utime(target, (past, past))
168
+ changed, _ = scan_stale(simple_project, store)
169
+ assert target not in changed