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.
- {codebrain-0.3.5 → codebrain-0.4.1}/PKG-INFO +1 -1
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/__init__.py +1 -1
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/cli.py +43 -24
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/store.py +26 -5
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/indexer.py +56 -7
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/mcp_lifecycle.py +49 -24
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/mcp_server.py +109 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/PKG-INFO +1 -1
- {codebrain-0.3.5 → codebrain-0.4.1}/pyproject.toml +1 -1
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_cli.py +19 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_indexer.py +54 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_mcp_lifecycle.py +61 -11
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_mcp_server.py +114 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/LICENSE +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/README.md +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/__main__.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/__init__.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/base.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/refactor.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/reviewer.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/actions/test_gen.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/agent_bridge.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/analyzer.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/api.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/api_models.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/architecture.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/comprehension.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/config.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/context.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/cross_query.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/cross_registry.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/diff_impact.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/env_migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/equivalence.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/export.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/frontend.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/__init__.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/query.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/graph/schema.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/hook_runner.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/hooks.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/kt.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/kt_video.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/llm.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/logging.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/memory/__init__.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/memory/store.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/modernize.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/onboard.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/__init__.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/base.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/cobol_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/config_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/csharp_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/dart_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/fortran_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/frontend_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/go_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/java_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/kotlin_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/models.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/mumps_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/plsql_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/python_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/registry.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/rust_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/schema_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/typescript_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/typescript_treesitter.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/parser/vue_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/py.typed +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/resolver.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/rewriter.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/schema_migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/settings.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/susa_auth.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/test_gaps.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/test_runner.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/tour.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/ui_migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/utils.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/validator.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/watcher/__init__.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain/watcher/file_watcher.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/SOURCES.txt +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/dependency_links.txt +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/entry_points.txt +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/requires.txt +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/codebrain.egg-info/top_level.txt +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/setup.cfg +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_actions.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_agent_bridge.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_analyzer.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_api.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_architecture.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ci.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_comprehension.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_context.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_contracts_real.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_coverage_gaps.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_cross_repo.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_csharp_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_dart_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_dataflow.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_dead_code_confidence.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_diff_impact.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_env_migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_equivalence.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_error_recovery.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_export.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_fingerprints.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_frontend.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_gate_battle.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_go_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_hooks.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_infra_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_install.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_integration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_java_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_jyotishyamitra.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_kotlin_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_kt.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_legacy_parsers.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_llm.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_memory.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_modernize.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_multi_project_cli.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_narratives.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_onboard.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_orm_detection.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_output_quality.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_plugin_system.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_production_hardening.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_query.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_codebase.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_features.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_frontend.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_repos.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_real_world.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_resolver.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_rewriter.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_rust_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_scale.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_scale_optimizations.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_scale_real.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_schema.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_schema_migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_schema_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_settings.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_store.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_test_runner.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_tour.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_translate.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ts_ast_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ts_parser_enhanced.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_typescript_parser.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_ui_migration.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_utils.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_validation_narratives.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_validator.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_validator_scenarios.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_vscode_extension.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_watch_validate.py +0 -0
- {codebrain-0.3.5 → codebrain-0.4.1}/tests/test_watcher.py +0 -0
- {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
|
+
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
|
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
if
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
96
|
+
return None
|
|
93
97
|
try:
|
|
94
98
|
proc = psutil.Process(pid)
|
|
95
99
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
96
|
-
return
|
|
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
|
|
105
|
+
return None
|
|
102
106
|
if parent is None:
|
|
103
|
-
return
|
|
107
|
+
return None
|
|
104
108
|
try:
|
|
105
109
|
name = (parent.name() or "").lower()
|
|
106
110
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
107
|
-
return
|
|
111
|
+
return None
|
|
108
112
|
if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
|
|
109
|
-
return
|
|
113
|
+
return parent.pid
|
|
110
114
|
proc = parent
|
|
111
|
-
return
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
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(
|
|
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
|
+
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.
|
|
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
|