codexa 0.4.0__py3-none-any.whl

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 (189) hide show
  1. codexa-0.4.0.dist-info/METADATA +650 -0
  2. codexa-0.4.0.dist-info/RECORD +189 -0
  3. codexa-0.4.0.dist-info/WHEEL +5 -0
  4. codexa-0.4.0.dist-info/entry_points.txt +2 -0
  5. codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. codexa-0.4.0.dist-info/top_level.txt +1 -0
  7. semantic_code_intelligence/__init__.py +5 -0
  8. semantic_code_intelligence/analysis/__init__.py +21 -0
  9. semantic_code_intelligence/analysis/ai_features.py +351 -0
  10. semantic_code_intelligence/bridge/__init__.py +28 -0
  11. semantic_code_intelligence/bridge/context_provider.py +245 -0
  12. semantic_code_intelligence/bridge/protocol.py +167 -0
  13. semantic_code_intelligence/bridge/server.py +348 -0
  14. semantic_code_intelligence/bridge/vscode.py +271 -0
  15. semantic_code_intelligence/ci/__init__.py +13 -0
  16. semantic_code_intelligence/ci/hooks.py +98 -0
  17. semantic_code_intelligence/ci/hotspots.py +272 -0
  18. semantic_code_intelligence/ci/impact.py +246 -0
  19. semantic_code_intelligence/ci/metrics.py +591 -0
  20. semantic_code_intelligence/ci/pr.py +412 -0
  21. semantic_code_intelligence/ci/quality.py +557 -0
  22. semantic_code_intelligence/ci/templates.py +164 -0
  23. semantic_code_intelligence/ci/trace.py +224 -0
  24. semantic_code_intelligence/cli/__init__.py +0 -0
  25. semantic_code_intelligence/cli/commands/__init__.py +0 -0
  26. semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
  27. semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
  28. semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
  29. semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
  30. semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
  31. semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
  32. semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
  33. semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
  34. semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
  35. semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
  36. semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
  37. semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
  38. semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
  39. semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
  40. semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
  41. semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
  42. semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
  43. semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
  44. semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
  45. semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
  46. semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
  47. semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
  48. semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
  49. semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
  50. semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
  51. semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
  52. semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
  53. semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
  54. semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
  55. semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
  56. semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
  57. semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
  58. semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
  59. semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
  60. semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
  61. semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
  62. semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
  63. semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
  64. semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
  65. semantic_code_intelligence/cli/main.py +65 -0
  66. semantic_code_intelligence/cli/router.py +92 -0
  67. semantic_code_intelligence/config/__init__.py +0 -0
  68. semantic_code_intelligence/config/settings.py +260 -0
  69. semantic_code_intelligence/context/__init__.py +19 -0
  70. semantic_code_intelligence/context/engine.py +429 -0
  71. semantic_code_intelligence/context/memory.py +253 -0
  72. semantic_code_intelligence/daemon/__init__.py +1 -0
  73. semantic_code_intelligence/daemon/watcher.py +515 -0
  74. semantic_code_intelligence/docs/__init__.py +1080 -0
  75. semantic_code_intelligence/embeddings/__init__.py +0 -0
  76. semantic_code_intelligence/embeddings/enhanced.py +131 -0
  77. semantic_code_intelligence/embeddings/generator.py +149 -0
  78. semantic_code_intelligence/embeddings/model_registry.py +100 -0
  79. semantic_code_intelligence/evolution/__init__.py +1 -0
  80. semantic_code_intelligence/evolution/budget_guard.py +111 -0
  81. semantic_code_intelligence/evolution/commit_manager.py +88 -0
  82. semantic_code_intelligence/evolution/context_builder.py +131 -0
  83. semantic_code_intelligence/evolution/engine.py +249 -0
  84. semantic_code_intelligence/evolution/patch_generator.py +229 -0
  85. semantic_code_intelligence/evolution/task_selector.py +214 -0
  86. semantic_code_intelligence/evolution/test_runner.py +111 -0
  87. semantic_code_intelligence/indexing/__init__.py +0 -0
  88. semantic_code_intelligence/indexing/chunker.py +174 -0
  89. semantic_code_intelligence/indexing/parallel.py +86 -0
  90. semantic_code_intelligence/indexing/scanner.py +146 -0
  91. semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
  92. semantic_code_intelligence/llm/__init__.py +62 -0
  93. semantic_code_intelligence/llm/cache.py +219 -0
  94. semantic_code_intelligence/llm/cached_provider.py +145 -0
  95. semantic_code_intelligence/llm/conversation.py +190 -0
  96. semantic_code_intelligence/llm/cross_refactor.py +272 -0
  97. semantic_code_intelligence/llm/investigation.py +274 -0
  98. semantic_code_intelligence/llm/mock_provider.py +77 -0
  99. semantic_code_intelligence/llm/ollama_provider.py +122 -0
  100. semantic_code_intelligence/llm/openai_provider.py +100 -0
  101. semantic_code_intelligence/llm/provider.py +92 -0
  102. semantic_code_intelligence/llm/rate_limiter.py +164 -0
  103. semantic_code_intelligence/llm/reasoning.py +438 -0
  104. semantic_code_intelligence/llm/safety.py +110 -0
  105. semantic_code_intelligence/llm/streaming.py +251 -0
  106. semantic_code_intelligence/lsp/__init__.py +609 -0
  107. semantic_code_intelligence/mcp/__init__.py +393 -0
  108. semantic_code_intelligence/parsing/__init__.py +19 -0
  109. semantic_code_intelligence/parsing/parser.py +375 -0
  110. semantic_code_intelligence/plugins/__init__.py +255 -0
  111. semantic_code_intelligence/plugins/examples/__init__.py +1 -0
  112. semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
  113. semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
  114. semantic_code_intelligence/scalability/__init__.py +205 -0
  115. semantic_code_intelligence/search/__init__.py +0 -0
  116. semantic_code_intelligence/search/formatter.py +123 -0
  117. semantic_code_intelligence/search/grep.py +361 -0
  118. semantic_code_intelligence/search/hybrid_search.py +170 -0
  119. semantic_code_intelligence/search/keyword_search.py +311 -0
  120. semantic_code_intelligence/search/section_expander.py +103 -0
  121. semantic_code_intelligence/services/__init__.py +0 -0
  122. semantic_code_intelligence/services/indexing_service.py +630 -0
  123. semantic_code_intelligence/services/search_service.py +269 -0
  124. semantic_code_intelligence/storage/__init__.py +0 -0
  125. semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
  126. semantic_code_intelligence/storage/hash_store.py +66 -0
  127. semantic_code_intelligence/storage/index_manifest.py +85 -0
  128. semantic_code_intelligence/storage/index_stats.py +138 -0
  129. semantic_code_intelligence/storage/query_history.py +160 -0
  130. semantic_code_intelligence/storage/symbol_registry.py +209 -0
  131. semantic_code_intelligence/storage/vector_store.py +297 -0
  132. semantic_code_intelligence/tests/__init__.py +0 -0
  133. semantic_code_intelligence/tests/test_ai_features.py +351 -0
  134. semantic_code_intelligence/tests/test_chunker.py +119 -0
  135. semantic_code_intelligence/tests/test_cli.py +188 -0
  136. semantic_code_intelligence/tests/test_config.py +154 -0
  137. semantic_code_intelligence/tests/test_context.py +381 -0
  138. semantic_code_intelligence/tests/test_embeddings.py +73 -0
  139. semantic_code_intelligence/tests/test_endtoend.py +1142 -0
  140. semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
  141. semantic_code_intelligence/tests/test_hash_store.py +79 -0
  142. semantic_code_intelligence/tests/test_logging.py +55 -0
  143. semantic_code_intelligence/tests/test_new_cli.py +138 -0
  144. semantic_code_intelligence/tests/test_parser.py +495 -0
  145. semantic_code_intelligence/tests/test_phase10.py +355 -0
  146. semantic_code_intelligence/tests/test_phase11.py +593 -0
  147. semantic_code_intelligence/tests/test_phase12.py +375 -0
  148. semantic_code_intelligence/tests/test_phase13.py +663 -0
  149. semantic_code_intelligence/tests/test_phase14.py +568 -0
  150. semantic_code_intelligence/tests/test_phase15.py +814 -0
  151. semantic_code_intelligence/tests/test_phase16.py +792 -0
  152. semantic_code_intelligence/tests/test_phase17.py +815 -0
  153. semantic_code_intelligence/tests/test_phase18.py +934 -0
  154. semantic_code_intelligence/tests/test_phase19.py +986 -0
  155. semantic_code_intelligence/tests/test_phase20.py +2753 -0
  156. semantic_code_intelligence/tests/test_phase20b.py +2058 -0
  157. semantic_code_intelligence/tests/test_phase20c.py +962 -0
  158. semantic_code_intelligence/tests/test_phase21.py +428 -0
  159. semantic_code_intelligence/tests/test_phase22.py +799 -0
  160. semantic_code_intelligence/tests/test_phase23.py +783 -0
  161. semantic_code_intelligence/tests/test_phase24.py +715 -0
  162. semantic_code_intelligence/tests/test_phase25.py +496 -0
  163. semantic_code_intelligence/tests/test_phase26.py +251 -0
  164. semantic_code_intelligence/tests/test_phase27.py +531 -0
  165. semantic_code_intelligence/tests/test_phase8.py +592 -0
  166. semantic_code_intelligence/tests/test_phase9.py +643 -0
  167. semantic_code_intelligence/tests/test_plugins.py +293 -0
  168. semantic_code_intelligence/tests/test_priority_features.py +727 -0
  169. semantic_code_intelligence/tests/test_router.py +41 -0
  170. semantic_code_intelligence/tests/test_scalability.py +138 -0
  171. semantic_code_intelligence/tests/test_scanner.py +125 -0
  172. semantic_code_intelligence/tests/test_search.py +160 -0
  173. semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
  174. semantic_code_intelligence/tests/test_tools.py +182 -0
  175. semantic_code_intelligence/tests/test_vector_store.py +151 -0
  176. semantic_code_intelligence/tests/test_watcher.py +211 -0
  177. semantic_code_intelligence/tools/__init__.py +442 -0
  178. semantic_code_intelligence/tools/executor.py +232 -0
  179. semantic_code_intelligence/tools/protocol.py +200 -0
  180. semantic_code_intelligence/tui/__init__.py +454 -0
  181. semantic_code_intelligence/utils/__init__.py +0 -0
  182. semantic_code_intelligence/utils/logging.py +112 -0
  183. semantic_code_intelligence/version.py +3 -0
  184. semantic_code_intelligence/web/__init__.py +11 -0
  185. semantic_code_intelligence/web/api.py +289 -0
  186. semantic_code_intelligence/web/server.py +397 -0
  187. semantic_code_intelligence/web/ui.py +659 -0
  188. semantic_code_intelligence/web/visualize.py +226 -0
  189. semantic_code_intelligence/workspace/__init__.py +427 -0
@@ -0,0 +1,355 @@
1
+ """Tests for Phase 10 — Multi-Repository Workspace Intelligence.
2
+
3
+ Covers: RepoEntry, WorkspaceManifest, Workspace model (persistence,
4
+ repo management, summary), and CLI workspace subcommands.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ import click
14
+ import pytest
15
+
16
+ from semantic_code_intelligence.workspace import (
17
+ RepoEntry,
18
+ Workspace,
19
+ WorkspaceManifest,
20
+ WORKSPACE_FILE,
21
+ )
22
+ from semantic_code_intelligence.cli.commands.workspace_cmd import workspace_cmd
23
+
24
+
25
+ # =========================================================================
26
+ # RepoEntry tests
27
+ # =========================================================================
28
+
29
+
30
+ class TestRepoEntry:
31
+ def test_to_dict(self):
32
+ entry = RepoEntry(name="backend", path="/repos/backend", last_indexed=1000.0, file_count=42, vector_count=100)
33
+ d = entry.to_dict()
34
+ assert d["name"] == "backend"
35
+ assert d["path"] == "/repos/backend"
36
+ assert d["last_indexed"] == 1000.0
37
+ assert d["file_count"] == 42
38
+ assert d["vector_count"] == 100
39
+
40
+ def test_from_dict_full(self):
41
+ data = {"name": "api", "path": "/code/api", "last_indexed": 999.0, "file_count": 5, "vector_count": 20}
42
+ entry = RepoEntry.from_dict(data)
43
+ assert entry.name == "api"
44
+ assert entry.path == "/code/api"
45
+ assert entry.last_indexed == 999.0
46
+
47
+ def test_from_dict_defaults(self):
48
+ data = {"name": "lib", "path": "/code/lib"}
49
+ entry = RepoEntry.from_dict(data)
50
+ assert entry.last_indexed == 0.0
51
+ assert entry.file_count == 0
52
+ assert entry.vector_count == 0
53
+
54
+ def test_roundtrip(self):
55
+ entry = RepoEntry(name="x", path="/x", last_indexed=1.5, file_count=3, vector_count=7)
56
+ reconstructed = RepoEntry.from_dict(entry.to_dict())
57
+ assert reconstructed.name == entry.name
58
+ assert reconstructed.path == entry.path
59
+ assert reconstructed.last_indexed == entry.last_indexed
60
+ assert reconstructed.file_count == entry.file_count
61
+ assert reconstructed.vector_count == entry.vector_count
62
+
63
+
64
+ # =========================================================================
65
+ # WorkspaceManifest tests
66
+ # =========================================================================
67
+
68
+
69
+ class TestWorkspaceManifest:
70
+ def test_empty_manifest(self):
71
+ m = WorkspaceManifest()
72
+ assert m.version == "1.0.0"
73
+ assert m.repos == []
74
+
75
+ def test_to_dict(self):
76
+ m = WorkspaceManifest(repos=[
77
+ RepoEntry(name="a", path="/a"),
78
+ RepoEntry(name="b", path="/b"),
79
+ ])
80
+ d = m.to_dict()
81
+ assert d["version"] == "1.0.0"
82
+ assert len(d["repos"]) == 2
83
+ assert d["repos"][0]["name"] == "a"
84
+
85
+ def test_from_dict(self):
86
+ data = {"version": "2.0.0", "repos": [{"name": "z", "path": "/z"}]}
87
+ m = WorkspaceManifest.from_dict(data)
88
+ assert m.version == "2.0.0"
89
+ assert len(m.repos) == 1
90
+ assert m.repos[0].name == "z"
91
+
92
+ def test_from_dict_defaults(self):
93
+ m = WorkspaceManifest.from_dict({})
94
+ assert m.version == "1.0.0"
95
+ assert m.repos == []
96
+
97
+ def test_roundtrip(self):
98
+ original = WorkspaceManifest(repos=[
99
+ RepoEntry(name="core", path="/core", file_count=10),
100
+ ])
101
+ restored = WorkspaceManifest.from_dict(original.to_dict())
102
+ assert restored.version == original.version
103
+ assert len(restored.repos) == 1
104
+ assert restored.repos[0].name == "core"
105
+ assert restored.repos[0].file_count == 10
106
+
107
+
108
+ # =========================================================================
109
+ # Workspace model tests
110
+ # =========================================================================
111
+
112
+
113
+ class TestWorkspaceProperties:
114
+ def test_root_and_directories(self, tmp_path):
115
+ ws = Workspace(tmp_path)
116
+ assert ws.root == tmp_path.resolve()
117
+ assert ws.config_dir == tmp_path.resolve() / ".codexa"
118
+ assert ws.repos_dir == tmp_path.resolve() / ".codexa" / "repos"
119
+ assert ws.manifest_path == tmp_path.resolve() / ".codexa" / WORKSPACE_FILE
120
+
121
+ def test_repos_empty(self, tmp_path):
122
+ ws = Workspace(tmp_path)
123
+ assert ws.repos == []
124
+
125
+ def test_repo_index_dir(self, tmp_path):
126
+ ws = Workspace(tmp_path)
127
+ assert ws.repo_index_dir("myrepo") == ws.repos_dir / "myrepo"
128
+
129
+
130
+ class TestWorkspacePersistence:
131
+ def test_save_creates_files(self, tmp_path):
132
+ ws = Workspace(tmp_path)
133
+ result_path = ws.save()
134
+ assert result_path.exists()
135
+ assert ws.config_dir.exists()
136
+ assert ws.repos_dir.exists()
137
+ data = json.loads(result_path.read_text())
138
+ assert data["version"] == "1.0.0"
139
+ assert data["repos"] == []
140
+
141
+ def test_load_roundtrip(self, tmp_path):
142
+ ws = Workspace(tmp_path)
143
+ repo_dir = tmp_path / "myrepo"
144
+ repo_dir.mkdir()
145
+ ws.add_repo("myrepo", repo_dir)
146
+ ws.save()
147
+
148
+ loaded = Workspace.load(tmp_path)
149
+ assert len(loaded.repos) == 1
150
+ assert loaded.repos[0].name == "myrepo"
151
+
152
+ def test_load_nonexistent_raises(self, tmp_path):
153
+ with pytest.raises(FileNotFoundError, match="No workspace found"):
154
+ Workspace.load(tmp_path / "nope")
155
+
156
+ def test_load_or_create_new(self, tmp_path):
157
+ ws = Workspace.load_or_create(tmp_path)
158
+ assert ws.manifest_path.exists()
159
+ assert ws.repos == []
160
+
161
+ def test_load_or_create_existing(self, tmp_path):
162
+ ws = Workspace(tmp_path)
163
+ repo_dir = tmp_path / "r"
164
+ repo_dir.mkdir()
165
+ ws.add_repo("r", repo_dir)
166
+ ws.save()
167
+
168
+ ws2 = Workspace.load_or_create(tmp_path)
169
+ assert len(ws2.repos) == 1
170
+
171
+
172
+ class TestWorkspaceRepoManagement:
173
+ def test_add_repo(self, tmp_path):
174
+ ws = Workspace(tmp_path)
175
+ repo_dir = tmp_path / "repo_a"
176
+ repo_dir.mkdir()
177
+ entry = ws.add_repo("repo_a", repo_dir)
178
+ assert entry.name == "repo_a"
179
+ assert entry.path == str(repo_dir.resolve())
180
+ assert len(ws.repos) == 1
181
+
182
+ def test_add_duplicate_raises(self, tmp_path):
183
+ ws = Workspace(tmp_path)
184
+ repo_dir = tmp_path / "d"
185
+ repo_dir.mkdir()
186
+ ws.add_repo("d", repo_dir)
187
+ with pytest.raises(ValueError, match="already registered"):
188
+ ws.add_repo("d", repo_dir)
189
+
190
+ def test_add_nonexistent_dir_raises(self, tmp_path):
191
+ ws = Workspace(tmp_path)
192
+ with pytest.raises(FileNotFoundError, match="Directory not found"):
193
+ ws.add_repo("missing", tmp_path / "nope")
194
+
195
+ def test_remove_repo(self, tmp_path):
196
+ ws = Workspace(tmp_path)
197
+ repo_dir = tmp_path / "rem"
198
+ repo_dir.mkdir()
199
+ ws.add_repo("rem", repo_dir)
200
+ assert ws.remove_repo("rem") is True
201
+ assert len(ws.repos) == 0
202
+
203
+ def test_remove_nonexistent(self, tmp_path):
204
+ ws = Workspace(tmp_path)
205
+ assert ws.remove_repo("ghost") is False
206
+
207
+ def test_get_repo(self, tmp_path):
208
+ ws = Workspace(tmp_path)
209
+ repo_dir = tmp_path / "g"
210
+ repo_dir.mkdir()
211
+ ws.add_repo("g", repo_dir)
212
+ assert ws.get_repo("g") is not None
213
+ assert ws.get_repo("g").name == "g"
214
+ assert ws.get_repo("nope") is None
215
+
216
+ def test_multiple_repos(self, tmp_path):
217
+ ws = Workspace(tmp_path)
218
+ for name in ["a", "b", "c"]:
219
+ d = tmp_path / name
220
+ d.mkdir()
221
+ ws.add_repo(name, d)
222
+ assert len(ws.repos) == 3
223
+ names = {r.name for r in ws.repos}
224
+ assert names == {"a", "b", "c"}
225
+
226
+
227
+ class TestWorkspaceSummary:
228
+ def test_summary_structure(self, tmp_path):
229
+ ws = Workspace(tmp_path)
230
+ repo_dir = tmp_path / "s"
231
+ repo_dir.mkdir()
232
+ ws.add_repo("s", repo_dir)
233
+ info = ws.summary()
234
+ assert info["root"] == str(tmp_path.resolve())
235
+ assert info["repo_count"] == 1
236
+ assert info["version"] == "1.0.0"
237
+ assert len(info["repos"]) == 1
238
+ assert info["repos"][0]["name"] == "s"
239
+
240
+
241
+ class TestWorkspaceIndexing:
242
+ def test_index_repo_not_registered(self, tmp_path):
243
+ ws = Workspace(tmp_path)
244
+ with pytest.raises(KeyError, match="not registered"):
245
+ ws.index_repo("nope")
246
+
247
+ @patch("semantic_code_intelligence.workspace.generate_embeddings")
248
+ @patch("semantic_code_intelligence.workspace.scan_repository")
249
+ def test_index_repo_empty(self, mock_scan, mock_embed, tmp_path):
250
+ """Indexing a repo with no files produces zero vectors."""
251
+ mock_scan.return_value = []
252
+ ws = Workspace(tmp_path)
253
+ repo_dir = tmp_path / "empty_repo"
254
+ repo_dir.mkdir()
255
+ ws.add_repo("empty_repo", repo_dir)
256
+ ws.save()
257
+ result = ws.index_repo("empty_repo")
258
+ assert result.files_indexed == 0
259
+ assert result.chunks_created == 0
260
+
261
+
262
+ class TestWorkspaceSearch:
263
+ def test_search_no_repos(self, tmp_path):
264
+ """Searching with no repos returns empty list."""
265
+ ws = Workspace(tmp_path)
266
+ ws.save()
267
+ with patch("semantic_code_intelligence.workspace.generate_embeddings") as mock_embed:
268
+ import numpy as np
269
+ mock_embed.return_value = np.zeros((1, 384))
270
+ results = ws.search("hello")
271
+ assert results == []
272
+
273
+
274
+ # =========================================================================
275
+ # CLI command tests
276
+ # =========================================================================
277
+
278
+
279
+ class TestWorkspaceCLI:
280
+ def test_command_group_name(self):
281
+ assert workspace_cmd.name == "workspace"
282
+
283
+ def test_subcommands_exist(self):
284
+ names = list(workspace_cmd.commands.keys())
285
+ assert "init" in names
286
+ assert "add" in names
287
+ assert "remove" in names
288
+ assert "list" in names
289
+ assert "index" in names
290
+ assert "search" in names
291
+
292
+ def test_subcommand_count(self):
293
+ assert len(workspace_cmd.commands) == 6
294
+
295
+ def test_init_creates_workspace(self, tmp_path):
296
+ from click.testing import CliRunner
297
+ runner = CliRunner()
298
+ result = runner.invoke(workspace_cmd, ["init", "--path", str(tmp_path)])
299
+ assert result.exit_code == 0
300
+ assert (tmp_path / ".codexa" / WORKSPACE_FILE).exists()
301
+
302
+ def test_add_without_init_fails(self, tmp_path):
303
+ from click.testing import CliRunner
304
+ runner = CliRunner()
305
+ repo = tmp_path / "repo"
306
+ repo.mkdir()
307
+ result = runner.invoke(workspace_cmd, ["add", "myrepo", str(repo), "--path", str(tmp_path)])
308
+ assert result.exit_code == 0 # click still exits 0 but prints error
309
+ assert "not initialised" in result.output.lower() or "error" in result.output.lower()
310
+
311
+ def test_add_and_list(self, tmp_path):
312
+ from click.testing import CliRunner
313
+ runner = CliRunner()
314
+ # Init
315
+ runner.invoke(workspace_cmd, ["init", "--path", str(tmp_path)])
316
+ # Add
317
+ repo = tmp_path / "backend"
318
+ repo.mkdir()
319
+ result = runner.invoke(workspace_cmd, ["add", "backend", str(repo), "--path", str(tmp_path)])
320
+ assert result.exit_code == 0
321
+ # List JSON
322
+ result = runner.invoke(workspace_cmd, ["list", "--json", "--path", str(tmp_path)])
323
+ assert result.exit_code == 0
324
+ data = json.loads(result.output)
325
+ assert data["repo_count"] == 1
326
+
327
+ def test_remove_repo(self, tmp_path):
328
+ from click.testing import CliRunner
329
+ runner = CliRunner()
330
+ runner.invoke(workspace_cmd, ["init", "--path", str(tmp_path)])
331
+ repo = tmp_path / "api"
332
+ repo.mkdir()
333
+ runner.invoke(workspace_cmd, ["add", "api", str(repo), "--path", str(tmp_path)])
334
+ result = runner.invoke(workspace_cmd, ["remove", "api", "--path", str(tmp_path)])
335
+ assert result.exit_code == 0
336
+ # Verify removed
337
+ result = runner.invoke(workspace_cmd, ["list", "--json", "--path", str(tmp_path)])
338
+ data = json.loads(result.output)
339
+ assert data["repo_count"] == 0
340
+
341
+ def test_remove_nonexistent_warns(self, tmp_path):
342
+ from click.testing import CliRunner
343
+ runner = CliRunner()
344
+ runner.invoke(workspace_cmd, ["init", "--path", str(tmp_path)])
345
+ result = runner.invoke(workspace_cmd, ["remove", "ghost", "--path", str(tmp_path)])
346
+ assert result.exit_code == 0
347
+ assert "not found" in result.output.lower()
348
+
349
+
350
+ class TestRouterIncludesWorkspace:
351
+ def test_workspace_command_registered(self):
352
+ from semantic_code_intelligence.cli.router import register_commands
353
+ group = click.Group(name="test")
354
+ register_commands(group)
355
+ assert "workspace" in group.commands