codespine 1.0.3__tar.gz → 1.0.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.
- {codespine-1.0.3 → codespine-1.0.5}/PKG-INFO +1 -1
- {codespine-1.0.3 → codespine-1.0.5}/codespine/__init__.py +1 -1
- {codespine-1.0.3 → codespine-1.0.5}/codespine/cli.py +50 -33
- {codespine-1.0.3 → codespine-1.0.5}/codespine/db/_cypher_compat.py +43 -1
- {codespine-1.0.3 → codespine-1.0.5}/codespine/sharding/store.py +21 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/PKG-INFO +1 -1
- {codespine-1.0.3 → codespine-1.0.5}/pyproject.toml +1 -1
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_cypher_compat.py +45 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_duckdb_store.py +39 -0
- {codespine-1.0.3 → codespine-1.0.5}/LICENSE +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/README.md +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/community.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/context.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/coupling.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/crossmodule.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/deadcode.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/flow.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/impact.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/cache/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/cache/result_cache.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/config.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/db/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/db/duckdb_store.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/db/schema.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/db/store.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/diff/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/diff/branch_diff.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/guide.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/call_resolver.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/di_resolver.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/engine.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/java_parser.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/symbol_builder.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/mcp/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/mcp/server.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/noise/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/noise/blocklist.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/git_state.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/merge.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/store.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/search/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/search/bm25.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/search/fuzzy.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/search/hybrid.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/search/rrf.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/search/vector.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/sharding/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/sharding/router.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/watch/__init__.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/watch/git_hook.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine/watch/watcher.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/SOURCES.txt +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/dependency_links.txt +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/entry_points.txt +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/requires.txt +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/top_level.txt +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/gindex.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/setup.cfg +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_branch_diff_normalize.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_call_resolver.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_community_detection.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_deadcode.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_index_and_hybrid.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_java_parser.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_multimodule_index.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_overlay.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_result_cache.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_search_ranking.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_sharding.py +0 -0
- {codespine-1.0.3 → codespine-1.0.5}/tests/test_store_recovery.py +0 -0
|
@@ -22,7 +22,6 @@ from codespine.analysis.deadcode import detect_dead_code
|
|
|
22
22
|
from codespine.analysis.flow import trace_execution_flows
|
|
23
23
|
from codespine.analysis.impact import analyze_impact
|
|
24
24
|
from codespine.config import SETTINGS
|
|
25
|
-
from codespine.db.store import GraphStore
|
|
26
25
|
from codespine.sharding import ShardedGraphStore, ShardRouter
|
|
27
26
|
from codespine.diff.branch_diff import compare_branches
|
|
28
27
|
from codespine.indexer.engine import JavaIndexer
|
|
@@ -56,6 +55,17 @@ def _current_repo_path() -> str:
|
|
|
56
55
|
return os.getcwd()
|
|
57
56
|
|
|
58
57
|
|
|
58
|
+
def _open_store(read_only: bool = True) -> ShardedGraphStore:
|
|
59
|
+
"""Open the sharded store with the backend configured in SETTINGS.
|
|
60
|
+
|
|
61
|
+
Every CLI command must go through this helper so the correct backend
|
|
62
|
+
(DuckDB or KùzuDB) is selected transparently. Direct ``GraphStore(...)``
|
|
63
|
+
calls were tied to the legacy single-DB KùzuDB layout and will fail on
|
|
64
|
+
any machine running the default DuckDB backend with sharded storage.
|
|
65
|
+
"""
|
|
66
|
+
return ShardedGraphStore(read_only=read_only)
|
|
67
|
+
|
|
68
|
+
|
|
59
69
|
def _db_size_bytes(path: str) -> int:
|
|
60
70
|
if os.path.isfile(path):
|
|
61
71
|
return os.path.getsize(path)
|
|
@@ -527,21 +537,28 @@ def analyse(path: str, full: bool, deep: bool, incremental_deep: bool, embed: bo
|
|
|
527
537
|
|
|
528
538
|
_phase("Analyzing git history...", "skipped (large repo; rerun with --deep)")
|
|
529
539
|
|
|
530
|
-
|
|
540
|
+
# Summary queries are best-effort: a translator miss or a transient
|
|
541
|
+
# DB error must never throw away a successful index.
|
|
542
|
+
def _safe_count(query: str) -> int:
|
|
543
|
+
try:
|
|
544
|
+
rows = root_shard_store.query_records(query)
|
|
545
|
+
return int(rows[0]["count"]) if rows else 0
|
|
546
|
+
except Exception as exc: # noqa: BLE001 - summary stats are non-critical
|
|
547
|
+
click.secho(f" (summary stat unavailable: {exc})", fg="yellow")
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
embeddings_generated = last_result.embeddings_generated if last_result else 0
|
|
551
|
+
vectors_stored = _safe_count(
|
|
531
552
|
"""
|
|
532
553
|
MATCH (s:Symbol)
|
|
533
554
|
WHERE s.embedding IS NOT NULL
|
|
534
555
|
RETURN count(s) as count
|
|
535
556
|
"""
|
|
536
|
-
)
|
|
537
|
-
embeddings_generated = last_result.embeddings_generated if last_result else 0
|
|
538
|
-
vectors_stored = int(vector_count[0]["count"]) if vector_count else embeddings_generated
|
|
557
|
+
) or embeddings_generated
|
|
539
558
|
_phase("Generating embeddings...", f"{vectors_stored} vectors stored")
|
|
540
559
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
symbols = int(symbol_count[0]["count"]) if symbol_count else 0
|
|
544
|
-
edges = int(edge_count[0]["count"]) if edge_count else 0
|
|
560
|
+
symbols = _safe_count("MATCH (s:Symbol) RETURN count(s) as count")
|
|
561
|
+
edges = _safe_count("MATCH ()-[r]->() RETURN count(r) as count")
|
|
545
562
|
elapsed = time.perf_counter() - started
|
|
546
563
|
|
|
547
564
|
if not embed:
|
|
@@ -584,7 +601,7 @@ def analyse(path: str, full: bool, deep: bool, incremental_deep: bool, embed: bo
|
|
|
584
601
|
@click.option("--json", "as_json", is_flag=True)
|
|
585
602
|
def search(query: str, k: int, as_json: bool) -> None:
|
|
586
603
|
"""Hybrid search (BM25 + vector + fuzzy + RRF)."""
|
|
587
|
-
store =
|
|
604
|
+
store = _open_store(read_only=True)
|
|
588
605
|
results = hybrid_search(store, query, k=k)
|
|
589
606
|
_echo_json(results, as_json)
|
|
590
607
|
|
|
@@ -595,7 +612,7 @@ def search(query: str, k: int, as_json: bool) -> None:
|
|
|
595
612
|
@click.option("--json", "as_json", is_flag=True)
|
|
596
613
|
def context(query: str, max_depth: int, as_json: bool) -> None:
|
|
597
614
|
"""Get one-shot symbol context: search + impact + community + flows."""
|
|
598
|
-
store =
|
|
615
|
+
store = _open_store(read_only=True)
|
|
599
616
|
result = build_symbol_context(store, query, max_depth=max_depth)
|
|
600
617
|
_echo_json(result, as_json)
|
|
601
618
|
|
|
@@ -606,7 +623,7 @@ def context(query: str, max_depth: int, as_json: bool) -> None:
|
|
|
606
623
|
@click.option("--json", "as_json", is_flag=True)
|
|
607
624
|
def impact(symbol: str, max_depth: int, as_json: bool) -> None:
|
|
608
625
|
"""Impact analysis grouped by depth with confidence scores."""
|
|
609
|
-
store =
|
|
626
|
+
store = _open_store(read_only=True)
|
|
610
627
|
result = analyze_impact(store, symbol, max_depth=max_depth)
|
|
611
628
|
_echo_json(result, as_json)
|
|
612
629
|
|
|
@@ -616,7 +633,7 @@ def impact(symbol: str, max_depth: int, as_json: bool) -> None:
|
|
|
616
633
|
@click.option("--json", "as_json", is_flag=True)
|
|
617
634
|
def deadcode(limit: int, as_json: bool) -> None:
|
|
618
635
|
"""Detect dead code candidates with Java-aware exemptions."""
|
|
619
|
-
store =
|
|
636
|
+
store = _open_store(read_only=True)
|
|
620
637
|
result = detect_dead_code(store, limit=limit)
|
|
621
638
|
_echo_json(result, as_json)
|
|
622
639
|
|
|
@@ -627,7 +644,7 @@ def deadcode(limit: int, as_json: bool) -> None:
|
|
|
627
644
|
@click.option("--json", "as_json", is_flag=True)
|
|
628
645
|
def flow(entry_symbol: str | None, max_depth: int, as_json: bool) -> None:
|
|
629
646
|
"""Trace execution flows from detected entry points."""
|
|
630
|
-
store =
|
|
647
|
+
store = _open_store(read_only=True)
|
|
631
648
|
result = trace_execution_flows(store, entry_symbol=entry_symbol, max_depth=max_depth)
|
|
632
649
|
_echo_json(result, as_json)
|
|
633
650
|
|
|
@@ -637,7 +654,7 @@ def flow(entry_symbol: str | None, max_depth: int, as_json: bool) -> None:
|
|
|
637
654
|
@click.option("--json", "as_json", is_flag=True)
|
|
638
655
|
def community(symbol: str | None, as_json: bool) -> None:
|
|
639
656
|
"""Detect communities or lookup community for a symbol."""
|
|
640
|
-
store =
|
|
657
|
+
store = _open_store(read_only=False)
|
|
641
658
|
detect_communities(store)
|
|
642
659
|
if symbol:
|
|
643
660
|
_echo_json(symbol_community(store, symbol), as_json)
|
|
@@ -655,7 +672,7 @@ def community(symbol: str | None, as_json: bool) -> None:
|
|
|
655
672
|
@click.option("--json", "as_json", is_flag=True)
|
|
656
673
|
def coupling(days: int, min_strength: float, min_cochanges: int, as_json: bool) -> None:
|
|
657
674
|
"""Compute and query git change coupling."""
|
|
658
|
-
store =
|
|
675
|
+
store = _open_store(read_only=False)
|
|
659
676
|
project = store.query_records("MATCH (p:Project) RETURN p.id as id LIMIT 1")
|
|
660
677
|
project_id = project[0]["id"] if project else os.path.basename(os.getcwd())
|
|
661
678
|
compute_coupling(store, os.getcwd(), project_id, days=days, min_strength=min_strength, min_cochanges=min_cochanges)
|
|
@@ -681,7 +698,7 @@ def coupling(days: int, min_strength: float, min_cochanges: int, as_json: bool)
|
|
|
681
698
|
@click.option("--promote-on-commit/--no-promote-on-commit", default=True, show_default=True)
|
|
682
699
|
def watch(path: str, global_interval: int, overlay_debounce_ms: int, promote_on_commit: bool) -> None:
|
|
683
700
|
"""Live re-indexing and periodic global analysis refresh."""
|
|
684
|
-
store =
|
|
701
|
+
store = _open_store(read_only=False)
|
|
685
702
|
run_watch_mode(
|
|
686
703
|
store,
|
|
687
704
|
os.path.abspath(path),
|
|
@@ -720,12 +737,12 @@ def stats(as_json: bool, show_shards: bool) -> None:
|
|
|
720
737
|
def _project_store(pid: str):
|
|
721
738
|
return sg.shard(pid)
|
|
722
739
|
|
|
723
|
-
if not
|
|
740
|
+
if not all_projects_meta:
|
|
724
741
|
click.secho("No projects indexed yet. Run 'codespine analyse <path>'.", fg="yellow")
|
|
725
742
|
return
|
|
726
743
|
|
|
727
744
|
rows = []
|
|
728
|
-
for p in
|
|
745
|
+
for p in all_projects_meta:
|
|
729
746
|
pid = p["id"]
|
|
730
747
|
# Route each query to the project's owning shard.
|
|
731
748
|
ps = _project_store(pid)
|
|
@@ -813,7 +830,7 @@ def stats(as_json: bool, show_shards: bool) -> None:
|
|
|
813
830
|
@click.option("--json", "as_json", is_flag=True)
|
|
814
831
|
def list_projects(as_json: bool) -> None:
|
|
815
832
|
"""List indexed projects."""
|
|
816
|
-
store =
|
|
833
|
+
store = _open_store(read_only=True)
|
|
817
834
|
projects = store.query_records("MATCH (p:Project) RETURN p.id as id, p.path as path, p.language as language ORDER BY p.id")
|
|
818
835
|
_echo_json(projects, as_json)
|
|
819
836
|
|
|
@@ -837,7 +854,7 @@ def status(as_json: bool) -> None:
|
|
|
837
854
|
pid = int(f.read().strip())
|
|
838
855
|
except Exception:
|
|
839
856
|
pid = None
|
|
840
|
-
store =
|
|
857
|
+
store = _open_store(read_only=True)
|
|
841
858
|
overlay = get_overlay_status(store)
|
|
842
859
|
|
|
843
860
|
# Check for stale PID file
|
|
@@ -875,7 +892,7 @@ def status(as_json: bool) -> None:
|
|
|
875
892
|
@click.option("--json", "as_json", is_flag=True)
|
|
876
893
|
def overlay_status_cmd(project: str | None, as_json: bool) -> None:
|
|
877
894
|
"""Show dirty overlay status by project/module."""
|
|
878
|
-
store =
|
|
895
|
+
store = _open_store(read_only=True)
|
|
879
896
|
_echo_json(get_overlay_status(store, project=project), as_json)
|
|
880
897
|
|
|
881
898
|
|
|
@@ -884,7 +901,7 @@ def overlay_status_cmd(project: str | None, as_json: bool) -> None:
|
|
|
884
901
|
@click.option("--json", "as_json", is_flag=True)
|
|
885
902
|
def overlay_clear_cmd(project: str | None, as_json: bool) -> None:
|
|
886
903
|
"""Clear dirty overlay data without touching the committed base index."""
|
|
887
|
-
store =
|
|
904
|
+
store = _open_store(read_only=False)
|
|
888
905
|
result = {"cleared": clear_overlay(store, project=project)}
|
|
889
906
|
_echo_json(result, as_json)
|
|
890
907
|
|
|
@@ -894,7 +911,7 @@ def overlay_clear_cmd(project: str | None, as_json: bool) -> None:
|
|
|
894
911
|
@click.option("--json", "as_json", is_flag=True)
|
|
895
912
|
def overlay_promote_cmd(project: str | None, as_json: bool) -> None:
|
|
896
913
|
"""Promote dirty overlay changes into the committed base index now."""
|
|
897
|
-
store =
|
|
914
|
+
store = _open_store(read_only=False)
|
|
898
915
|
result = {"promoted": promote_overlay(store, project=project, require_head_change=False)}
|
|
899
916
|
_echo_json(result, as_json)
|
|
900
917
|
|
|
@@ -904,7 +921,7 @@ def overlay_promote_cmd(project: str | None, as_json: bool) -> None:
|
|
|
904
921
|
@click.option("--json", "as_json", is_flag=True)
|
|
905
922
|
def cypher(query: str, as_json: bool) -> None:
|
|
906
923
|
"""Run a raw Cypher query against the graph DB."""
|
|
907
|
-
store =
|
|
924
|
+
store = _open_store(read_only=True)
|
|
908
925
|
try:
|
|
909
926
|
result = store.query_records(query)
|
|
910
927
|
except Exception as exc:
|
|
@@ -948,7 +965,7 @@ def clear_project_cmd(project_id: str, allow_running: bool) -> None:
|
|
|
948
965
|
click.secho("Stop MCP first ('codespine stop') to modify index.", fg="yellow")
|
|
949
966
|
return
|
|
950
967
|
try:
|
|
951
|
-
store =
|
|
968
|
+
store = _open_store(read_only=False)
|
|
952
969
|
recs = store.query_records(
|
|
953
970
|
"MATCH (p:Project) WHERE p.id = $pid RETURN p.id as id, p.path as path",
|
|
954
971
|
{"pid": project_id},
|
|
@@ -974,7 +991,7 @@ def clear_project_cmd(project_id: str, allow_running: bool) -> None:
|
|
|
974
991
|
except OSError:
|
|
975
992
|
pass
|
|
976
993
|
# Update the read replica so read-only callers (stats, MCP) see the change.
|
|
977
|
-
|
|
994
|
+
store.snapshot_to_read_replica()
|
|
978
995
|
click.secho(f"Cleared project '{project_id}' (was at {project_path}).", fg="green")
|
|
979
996
|
|
|
980
997
|
|
|
@@ -991,12 +1008,12 @@ def clear_index_cmd(allow_running: bool) -> None:
|
|
|
991
1008
|
click.secho("Stop MCP first ('codespine stop') to modify index.", fg="yellow")
|
|
992
1009
|
return
|
|
993
1010
|
try:
|
|
994
|
-
store =
|
|
1011
|
+
store = _open_store(read_only=False)
|
|
995
1012
|
projects = store.query_records("MATCH (p:Project) RETURN p.id as id")
|
|
996
1013
|
except Exception:
|
|
997
1014
|
# DB is corrupted — can't even open it. Force-delete everything.
|
|
998
1015
|
click.secho("DB is corrupted. Running force-reset instead...", fg="yellow")
|
|
999
|
-
removed =
|
|
1016
|
+
removed = ShardedGraphStore(read_only=False).force_delete_all_data()
|
|
1000
1017
|
click.secho(f"Force-reset complete. {len(removed)} path(s) removed. Index is now empty.", fg="green")
|
|
1001
1018
|
return
|
|
1002
1019
|
try:
|
|
@@ -1004,7 +1021,7 @@ def clear_index_cmd(allow_running: bool) -> None:
|
|
|
1004
1021
|
except Exception as exc:
|
|
1005
1022
|
# rebuild_empty_db failed even with fallbacks — force-delete.
|
|
1006
1023
|
click.secho(f"rebuild failed ({exc}). Running force-reset...", fg="yellow")
|
|
1007
|
-
|
|
1024
|
+
store.force_delete_all_data()
|
|
1008
1025
|
click.secho("Force-reset complete. Index is now empty.", fg="green")
|
|
1009
1026
|
return
|
|
1010
1027
|
store.overlay_store.clear_all()
|
|
@@ -1017,7 +1034,7 @@ def clear_index_cmd(allow_running: bool) -> None:
|
|
|
1017
1034
|
pass
|
|
1018
1035
|
# Publish an empty read replica so that read-only callers (stats, MCP)
|
|
1019
1036
|
# immediately see the cleared state and the MCP daemon hot-reloads.
|
|
1020
|
-
|
|
1037
|
+
store.snapshot_to_read_replica()
|
|
1021
1038
|
click.secho(f"Cleared {len(projects)} project(s). Index is now empty.", fg="green")
|
|
1022
1039
|
|
|
1023
1040
|
|
|
@@ -1038,7 +1055,7 @@ def force_reset_cmd(force: bool) -> None:
|
|
|
1038
1055
|
):
|
|
1039
1056
|
click.echo("Aborted.")
|
|
1040
1057
|
return
|
|
1041
|
-
removed =
|
|
1058
|
+
removed = ShardedGraphStore(read_only=False).force_delete_all_data()
|
|
1042
1059
|
if removed:
|
|
1043
1060
|
for p in removed:
|
|
1044
1061
|
click.echo(f" removed: {p}")
|
|
@@ -1177,7 +1194,7 @@ def install_model() -> None:
|
|
|
1177
1194
|
@main.command("run-mcp", hidden=True)
|
|
1178
1195
|
def run_mcp() -> None:
|
|
1179
1196
|
"""Run MCP server in stdio mode."""
|
|
1180
|
-
store =
|
|
1197
|
+
store = _open_store(read_only=True)
|
|
1181
1198
|
mcp = build_mcp_server(store, repo_path_provider=_current_repo_path)
|
|
1182
1199
|
mcp.run()
|
|
1183
1200
|
|
|
@@ -77,7 +77,47 @@ def translate(cypher: str, params: dict[str, Any] | None = None) -> tuple[str, d
|
|
|
77
77
|
# Internal translation pipeline
|
|
78
78
|
# ---------------------------------------------------------------------------
|
|
79
79
|
|
|
80
|
+
_ALL_EDGE_TABLES = (
|
|
81
|
+
"calls",
|
|
82
|
+
"references_type",
|
|
83
|
+
"injects",
|
|
84
|
+
"binds_interface",
|
|
85
|
+
"community_members",
|
|
86
|
+
"flow_members",
|
|
87
|
+
"co_changed_with",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _translate_anonymous_edge_count(cypher: str) -> str | None:
|
|
92
|
+
"""Handle `MATCH ()-[r]->() RETURN count(r) [as X]`.
|
|
93
|
+
|
|
94
|
+
Anonymous edge patterns carry no labels, so the generic translator
|
|
95
|
+
cannot derive a FROM table. We special-case the count-all-edges
|
|
96
|
+
pattern by unioning row-counts across every edge table in the
|
|
97
|
+
schema, which is what CodeSpine actually asks for.
|
|
98
|
+
"""
|
|
99
|
+
q = re.sub(r"\s+", " ", cypher.strip())
|
|
100
|
+
# Accept MATCH ()-[r]->() RETURN count(r) [as alias]
|
|
101
|
+
m = re.match(
|
|
102
|
+
r"(?i)MATCH\s*\(\s*\)\s*-\s*\[\s*(\w+)?\s*\]\s*->\s*\(\s*\)\s*"
|
|
103
|
+
r"RETURN\s+count\s*\(\s*\*?\w*\s*\)\s*(?:as\s+(\w+))?\s*$",
|
|
104
|
+
q,
|
|
105
|
+
)
|
|
106
|
+
if not m:
|
|
107
|
+
return None
|
|
108
|
+
alias = m.group(2) or "count"
|
|
109
|
+
unions = " UNION ALL ".join(
|
|
110
|
+
f"SELECT COUNT(*) AS c FROM {tbl}" for tbl in _ALL_EDGE_TABLES
|
|
111
|
+
)
|
|
112
|
+
return f"SELECT COALESCE(SUM(c), 0) AS {alias} FROM ({unions}) t"
|
|
113
|
+
|
|
114
|
+
|
|
80
115
|
def _translate(cypher: str) -> str:
|
|
116
|
+
# Fast-path: anonymous edge-count query used by `analyse` summary.
|
|
117
|
+
special = _translate_anonymous_edge_count(cypher)
|
|
118
|
+
if special is not None:
|
|
119
|
+
return special
|
|
120
|
+
|
|
81
121
|
q = re.sub(r"\s+", " ", cypher.strip())
|
|
82
122
|
|
|
83
123
|
# Collect node aliases before we start mangling the string
|
|
@@ -248,7 +288,9 @@ def _translate(cypher: str) -> str:
|
|
|
248
288
|
if tbl not in {et.split()[0] for et in edge_from}
|
|
249
289
|
or any(alias in ef for ef in edge_from)]
|
|
250
290
|
# Deduplicate: edge_from entries are already included via aliases
|
|
251
|
-
|
|
291
|
+
# Fallback: empty DuckDB-valid relation so an un-matched pattern
|
|
292
|
+
# degrades to zero rows instead of crashing with "table dual missing".
|
|
293
|
+
from_str = ", ".join(from_parts) if from_parts else "(SELECT 1 WHERE 1=0) _empty(x)"
|
|
252
294
|
|
|
253
295
|
# ----------------------------------------------------------------
|
|
254
296
|
# 7. Transform WHERE conditions
|
|
@@ -299,6 +299,27 @@ class ShardedGraphStore:
|
|
|
299
299
|
removed.extend(store.force_delete_all_data())
|
|
300
300
|
return removed
|
|
301
301
|
|
|
302
|
+
def clear_analysis_artifacts(self) -> None:
|
|
303
|
+
"""Fan-out: clear analysis artifacts (communities, flows, dead code) on every shard."""
|
|
304
|
+
for store in self.all_shards():
|
|
305
|
+
try:
|
|
306
|
+
store.clear_analysis_artifacts()
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
LOGGER.warning("clear_analysis_artifacts failed on shard: %s", exc)
|
|
309
|
+
|
|
310
|
+
def rebuild_empty_db(self) -> None:
|
|
311
|
+
"""Fan-out: rebuild each shard as an empty database."""
|
|
312
|
+
for store in self.all_shards():
|
|
313
|
+
try:
|
|
314
|
+
store.rebuild_empty_db()
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
LOGGER.warning("rebuild_empty_db failed on shard: %s", exc)
|
|
317
|
+
|
|
318
|
+
def snapshot_to_read_replica(self, background: bool = False) -> bool:
|
|
319
|
+
"""Alias for ``snapshot_all`` — matches GraphStore's API."""
|
|
320
|
+
self.snapshot_all(background=background)
|
|
321
|
+
return True
|
|
322
|
+
|
|
302
323
|
def describe(self) -> dict:
|
|
303
324
|
"""Return a human-readable description of the shard topology."""
|
|
304
325
|
shard_info = []
|
|
@@ -301,3 +301,48 @@ def test_no_return_gives_star():
|
|
|
301
301
|
sql = _translate("MATCH (n:File)")
|
|
302
302
|
assert "SELECT" in sql
|
|
303
303
|
assert "files" in sql
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Anonymous edge-count pattern (v1.0.5 regression)
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_anonymous_edge_count():
|
|
312
|
+
"""`MATCH ()-[r]->() RETURN count(r) as count` — the query used by
|
|
313
|
+
`codespine analyse` to report total edges — must translate to a
|
|
314
|
+
DuckDB-valid query instead of falling through to `FROM dual`.
|
|
315
|
+
"""
|
|
316
|
+
sql = _translate("MATCH ()-[r]->() RETURN count(r) as count")
|
|
317
|
+
# Must reference real edge tables, not the Oracle-style `dual`.
|
|
318
|
+
assert "dual" not in sql.lower()
|
|
319
|
+
assert "calls" in sql
|
|
320
|
+
assert "references_type" in sql
|
|
321
|
+
assert "injects" in sql
|
|
322
|
+
assert "binds_interface" in sql
|
|
323
|
+
assert "community_members" in sql
|
|
324
|
+
assert "flow_members" in sql
|
|
325
|
+
assert "co_changed_with" in sql
|
|
326
|
+
# Alias should survive so callers can read row["count"].
|
|
327
|
+
assert "AS count" in sql or "as count" in sql
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_anonymous_edge_count_no_alias():
|
|
331
|
+
sql = _translate("MATCH ()-[r]->() RETURN count(r)")
|
|
332
|
+
assert "dual" not in sql.lower()
|
|
333
|
+
assert "calls" in sql
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_anonymous_edge_count_unnamed_rel():
|
|
337
|
+
# `MATCH ()-[]->()` (no rel variable) should also be handled
|
|
338
|
+
sql = _translate("MATCH ()-[]->() RETURN count(*) as count")
|
|
339
|
+
assert "dual" not in sql.lower()
|
|
340
|
+
assert "calls" in sql
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_unmatched_pattern_uses_safe_fallback():
|
|
344
|
+
"""If translator can't derive a FROM table, emit an empty DuckDB
|
|
345
|
+
relation rather than Oracle's `dual`.
|
|
346
|
+
"""
|
|
347
|
+
sql = _translate("MATCH (x:NotARealLabel) RETURN x.id")
|
|
348
|
+
assert "dual" not in sql.lower()
|
|
@@ -446,3 +446,42 @@ def test_corrupt_file_at_db_path_is_replaced(tmp_path: Path):
|
|
|
446
446
|
store = DuckDBStore(db_path_override=db_path, snapshot_path_override=snap_path)
|
|
447
447
|
rows = store.query_records("SELECT * FROM projects")
|
|
448
448
|
assert rows == []
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_legacy_kuzu_dirs_at_both_paths_are_removed(tmp_path: Path):
|
|
452
|
+
"""Regression: both db and db_read as KùzuDB directories (the exact
|
|
453
|
+
scenario from the field bug in v1.0.2)."""
|
|
454
|
+
db_path = str(tmp_path / "db")
|
|
455
|
+
snap_path = str(tmp_path / "db_read")
|
|
456
|
+
|
|
457
|
+
# Simulate KùzuDB directories at BOTH paths
|
|
458
|
+
for p in (db_path, snap_path):
|
|
459
|
+
os.makedirs(p)
|
|
460
|
+
(Path(p) / "catalog.kz").write_bytes(b"\x00" * 64)
|
|
461
|
+
(Path(p) / "data.kz").write_bytes(b"\x00" * 1024)
|
|
462
|
+
|
|
463
|
+
# Read-only open: legacy code would pick snap, fail, fall back to db, fail, raise.
|
|
464
|
+
# New code pre-sanitizes both paths, then returns an in-memory empty DB.
|
|
465
|
+
store = DuckDBStore(read_only=True, db_path_override=db_path, snapshot_path_override=snap_path)
|
|
466
|
+
rows = store.query_records("SELECT * FROM projects")
|
|
467
|
+
assert rows == []
|
|
468
|
+
# Both paths should be gone
|
|
469
|
+
assert not os.path.exists(db_path)
|
|
470
|
+
assert not os.path.exists(snap_path)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def test_sharded_store_stats_flow_with_stale_kuzu_dirs(tmp_path: Path):
|
|
474
|
+
"""Regression: ShardedGraphStore.list_project_metadata() must not crash
|
|
475
|
+
when every shard path is a stale KùzuDB directory (the failing
|
|
476
|
+
'codespine stats' scenario)."""
|
|
477
|
+
shards_dir = tmp_path / "shards"
|
|
478
|
+
# Pre-create 4 shards with legacy KùzuDB-style directories at both paths
|
|
479
|
+
for i in range(4):
|
|
480
|
+
(shards_dir / str(i)).mkdir(parents=True)
|
|
481
|
+
(shards_dir / str(i) / "db").mkdir()
|
|
482
|
+
(shards_dir / str(i) / "db" / "catalog.kz").write_bytes(b"\x00" * 32)
|
|
483
|
+
(shards_dir / str(i) / "db_read").mkdir()
|
|
484
|
+
|
|
485
|
+
sg = ShardedGraphStore(read_only=True, shards_dir=str(shards_dir), backend="duckdb")
|
|
486
|
+
# This is what `codespine stats` does — it must not raise.
|
|
487
|
+
assert sg.list_project_metadata() == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|