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.
Files changed (73) hide show
  1. {codespine-1.0.3 → codespine-1.0.5}/PKG-INFO +1 -1
  2. {codespine-1.0.3 → codespine-1.0.5}/codespine/__init__.py +1 -1
  3. {codespine-1.0.3 → codespine-1.0.5}/codespine/cli.py +50 -33
  4. {codespine-1.0.3 → codespine-1.0.5}/codespine/db/_cypher_compat.py +43 -1
  5. {codespine-1.0.3 → codespine-1.0.5}/codespine/sharding/store.py +21 -0
  6. {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/PKG-INFO +1 -1
  7. {codespine-1.0.3 → codespine-1.0.5}/pyproject.toml +1 -1
  8. {codespine-1.0.3 → codespine-1.0.5}/tests/test_cypher_compat.py +45 -0
  9. {codespine-1.0.3 → codespine-1.0.5}/tests/test_duckdb_store.py +39 -0
  10. {codespine-1.0.3 → codespine-1.0.5}/LICENSE +0 -0
  11. {codespine-1.0.3 → codespine-1.0.5}/README.md +0 -0
  12. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/__init__.py +0 -0
  13. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/community.py +0 -0
  14. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/context.py +0 -0
  15. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/coupling.py +0 -0
  16. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/crossmodule.py +0 -0
  17. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/deadcode.py +0 -0
  18. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/flow.py +0 -0
  19. {codespine-1.0.3 → codespine-1.0.5}/codespine/analysis/impact.py +0 -0
  20. {codespine-1.0.3 → codespine-1.0.5}/codespine/cache/__init__.py +0 -0
  21. {codespine-1.0.3 → codespine-1.0.5}/codespine/cache/result_cache.py +0 -0
  22. {codespine-1.0.3 → codespine-1.0.5}/codespine/config.py +0 -0
  23. {codespine-1.0.3 → codespine-1.0.5}/codespine/db/__init__.py +0 -0
  24. {codespine-1.0.3 → codespine-1.0.5}/codespine/db/duckdb_store.py +0 -0
  25. {codespine-1.0.3 → codespine-1.0.5}/codespine/db/schema.py +0 -0
  26. {codespine-1.0.3 → codespine-1.0.5}/codespine/db/store.py +0 -0
  27. {codespine-1.0.3 → codespine-1.0.5}/codespine/diff/__init__.py +0 -0
  28. {codespine-1.0.3 → codespine-1.0.5}/codespine/diff/branch_diff.py +0 -0
  29. {codespine-1.0.3 → codespine-1.0.5}/codespine/guide.py +0 -0
  30. {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/__init__.py +0 -0
  31. {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/call_resolver.py +0 -0
  32. {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/di_resolver.py +0 -0
  33. {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/engine.py +0 -0
  34. {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/java_parser.py +0 -0
  35. {codespine-1.0.3 → codespine-1.0.5}/codespine/indexer/symbol_builder.py +0 -0
  36. {codespine-1.0.3 → codespine-1.0.5}/codespine/mcp/__init__.py +0 -0
  37. {codespine-1.0.3 → codespine-1.0.5}/codespine/mcp/server.py +0 -0
  38. {codespine-1.0.3 → codespine-1.0.5}/codespine/noise/__init__.py +0 -0
  39. {codespine-1.0.3 → codespine-1.0.5}/codespine/noise/blocklist.py +0 -0
  40. {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/__init__.py +0 -0
  41. {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/git_state.py +0 -0
  42. {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/merge.py +0 -0
  43. {codespine-1.0.3 → codespine-1.0.5}/codespine/overlay/store.py +0 -0
  44. {codespine-1.0.3 → codespine-1.0.5}/codespine/search/__init__.py +0 -0
  45. {codespine-1.0.3 → codespine-1.0.5}/codespine/search/bm25.py +0 -0
  46. {codespine-1.0.3 → codespine-1.0.5}/codespine/search/fuzzy.py +0 -0
  47. {codespine-1.0.3 → codespine-1.0.5}/codespine/search/hybrid.py +0 -0
  48. {codespine-1.0.3 → codespine-1.0.5}/codespine/search/rrf.py +0 -0
  49. {codespine-1.0.3 → codespine-1.0.5}/codespine/search/vector.py +0 -0
  50. {codespine-1.0.3 → codespine-1.0.5}/codespine/sharding/__init__.py +0 -0
  51. {codespine-1.0.3 → codespine-1.0.5}/codespine/sharding/router.py +0 -0
  52. {codespine-1.0.3 → codespine-1.0.5}/codespine/watch/__init__.py +0 -0
  53. {codespine-1.0.3 → codespine-1.0.5}/codespine/watch/git_hook.py +0 -0
  54. {codespine-1.0.3 → codespine-1.0.5}/codespine/watch/watcher.py +0 -0
  55. {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/SOURCES.txt +0 -0
  56. {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/dependency_links.txt +0 -0
  57. {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/entry_points.txt +0 -0
  58. {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/requires.txt +0 -0
  59. {codespine-1.0.3 → codespine-1.0.5}/codespine.egg-info/top_level.txt +0 -0
  60. {codespine-1.0.3 → codespine-1.0.5}/gindex.py +0 -0
  61. {codespine-1.0.3 → codespine-1.0.5}/setup.cfg +0 -0
  62. {codespine-1.0.3 → codespine-1.0.5}/tests/test_branch_diff_normalize.py +0 -0
  63. {codespine-1.0.3 → codespine-1.0.5}/tests/test_call_resolver.py +0 -0
  64. {codespine-1.0.3 → codespine-1.0.5}/tests/test_community_detection.py +0 -0
  65. {codespine-1.0.3 → codespine-1.0.5}/tests/test_deadcode.py +0 -0
  66. {codespine-1.0.3 → codespine-1.0.5}/tests/test_index_and_hybrid.py +0 -0
  67. {codespine-1.0.3 → codespine-1.0.5}/tests/test_java_parser.py +0 -0
  68. {codespine-1.0.3 → codespine-1.0.5}/tests/test_multimodule_index.py +0 -0
  69. {codespine-1.0.3 → codespine-1.0.5}/tests/test_overlay.py +0 -0
  70. {codespine-1.0.3 → codespine-1.0.5}/tests/test_result_cache.py +0 -0
  71. {codespine-1.0.3 → codespine-1.0.5}/tests/test_search_ranking.py +0 -0
  72. {codespine-1.0.3 → codespine-1.0.5}/tests/test_sharding.py +0 -0
  73. {codespine-1.0.3 → codespine-1.0.5}/tests/test_store_recovery.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codespine
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: Local Java code intelligence indexer backed by a graph database
5
5
  Author: CodeSpine contributors
6
6
  License: MIT License
@@ -1,4 +1,4 @@
1
1
  """CodeSpine package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "1.0.3"
4
+ __version__ = "1.0.5"
@@ -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
- vector_count = root_shard_store.query_records(
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
- symbol_count = root_shard_store.query_records("MATCH (s:Symbol) RETURN count(s) as count")
542
- edge_count = root_shard_store.query_records("MATCH ()-[r]->() RETURN count(r) as count")
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=False)
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 = GraphStore(read_only=False)
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 = GraphStore(read_only=False)
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 projects:
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 projects:
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=False)
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 = GraphStore(read_only=False)
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 = GraphStore(read_only=True)
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 = GraphStore(read_only=False)
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
- GraphStore.snapshot_to_read_replica()
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 = GraphStore(read_only=False)
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 = GraphStore.force_delete_all_data()
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
- GraphStore.force_delete_all_data()
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
- GraphStore.snapshot_to_read_replica()
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 = GraphStore.force_delete_all_data()
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 = GraphStore(read_only=True)
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
- from_str = ", ".join(from_parts) if from_parts else "dual"
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 = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codespine
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: Local Java code intelligence indexer backed by a graph database
5
5
  Author: CodeSpine contributors
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codespine"
7
- version = "1.0.3"
7
+ version = "1.0.5"
8
8
  description = "Local Java code intelligence indexer backed by a graph database"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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