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,799 @@
1
+ """Phase 22 — LLM Caching + Rate Limiting.
2
+
3
+ Tests verify:
4
+ 1. LLMCache — in-memory and disk-backed caching with TTL expiration
5
+ 2. CacheStats — hit/miss/eviction tracking
6
+ 3. RateLimiter — sliding-window RPM/TPM enforcement
7
+ 4. RateLimitExceeded — exception behaviour
8
+ 5. CachedProvider — transparent wrapper around any LLMProvider
9
+ 6. LLMConfig — new cache/rate-limit config fields
10
+ 7. _wrap_provider integration — CLI commands wire up caching
11
+ 8. End-to-end — full caching + rate limiting flow
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any
21
+ from unittest.mock import MagicMock, patch
22
+
23
+ import pytest
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Imports under test
27
+ # ---------------------------------------------------------------------------
28
+
29
+ from semantic_code_intelligence.llm.cache import CacheStats, LLMCache
30
+ from semantic_code_intelligence.llm.cached_provider import CachedProvider
31
+ from semantic_code_intelligence.llm.mock_provider import MockProvider
32
+ from semantic_code_intelligence.llm.provider import (
33
+ LLMMessage,
34
+ LLMProvider,
35
+ LLMResponse,
36
+ MessageRole,
37
+ )
38
+ from semantic_code_intelligence.llm.rate_limiter import (
39
+ RateLimitExceeded,
40
+ RateLimiter,
41
+ RateLimiterStats,
42
+ )
43
+
44
+ _PROJECT_ROOT = Path(__file__).resolve().parents[2]
45
+ _SRC = _PROJECT_ROOT / "semantic_code_intelligence"
46
+
47
+
48
+ # ═══════════════════════════════════════════════════════════════════════════
49
+ # 1 — LLMCache: core operations
50
+ # ═══════════════════════════════════════════════════════════════════════════
51
+
52
+
53
+ class TestLLMCacheBasic:
54
+ """Basic get/put/clear operations."""
55
+
56
+ def test_cache_miss_returns_none(self) -> None:
57
+ cache = LLMCache()
58
+ result = cache.get("openai", "gpt-4", prompt="hello")
59
+ assert result is None
60
+
61
+ def test_cache_put_and_get(self) -> None:
62
+ cache = LLMCache()
63
+ response = LLMResponse(content="world", model="gpt-4", provider="openai")
64
+ cache.put(response, "openai", "gpt-4", prompt="hello")
65
+ cached = cache.get("openai", "gpt-4", prompt="hello")
66
+ assert cached is not None
67
+ assert cached.content == "world"
68
+
69
+ def test_cache_put_get_with_messages(self) -> None:
70
+ cache = LLMCache()
71
+ msgs = [LLMMessage(role=MessageRole.USER, content="hi")]
72
+ response = LLMResponse(content="hello!", model="gpt-4", provider="openai")
73
+ cache.put(response, "openai", "gpt-4", messages=msgs)
74
+ cached = cache.get("openai", "gpt-4", messages=msgs)
75
+ assert cached is not None
76
+ assert cached.content == "hello!"
77
+
78
+ def test_cache_different_prompts_separate(self) -> None:
79
+ cache = LLMCache()
80
+ r1 = LLMResponse(content="a", model="m", provider="p")
81
+ r2 = LLMResponse(content="b", model="m", provider="p")
82
+ cache.put(r1, "p", "m", prompt="x")
83
+ cache.put(r2, "p", "m", prompt="y")
84
+ assert cache.get("p", "m", prompt="x") is not None
85
+ assert cache.get("p", "m", prompt="x").content == "a" # type: ignore[union-attr]
86
+ assert cache.get("p", "m", prompt="y").content == "b" # type: ignore[union-attr]
87
+
88
+ def test_cache_clear(self) -> None:
89
+ cache = LLMCache()
90
+ cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
91
+ assert cache.size == 1
92
+ cache.clear()
93
+ assert cache.size == 0
94
+ assert cache.get("p", "m", prompt="q") is None
95
+
96
+ def test_cache_size_property(self) -> None:
97
+ cache = LLMCache()
98
+ assert cache.size == 0
99
+ cache.put(LLMResponse(content="x"), "p", "m", prompt="q1")
100
+ assert cache.size == 1
101
+ cache.put(LLMResponse(content="y"), "p", "m", prompt="q2")
102
+ assert cache.size == 2
103
+
104
+ def test_cache_different_temperature_separate_keys(self) -> None:
105
+ cache = LLMCache()
106
+ r1 = LLMResponse(content="cold")
107
+ r2 = LLMResponse(content="hot")
108
+ cache.put(r1, "p", "m", prompt="q", temperature=0.0)
109
+ cache.put(r2, "p", "m", prompt="q", temperature=1.0)
110
+ assert cache.get("p", "m", prompt="q", temperature=0.0).content == "cold" # type: ignore[union-attr]
111
+ assert cache.get("p", "m", prompt="q", temperature=1.0).content == "hot" # type: ignore[union-attr]
112
+
113
+ def test_cache_different_max_tokens_separate_keys(self) -> None:
114
+ cache = LLMCache()
115
+ r1 = LLMResponse(content="short")
116
+ r2 = LLMResponse(content="long")
117
+ cache.put(r1, "p", "m", prompt="q", max_tokens=100)
118
+ cache.put(r2, "p", "m", prompt="q", max_tokens=4000)
119
+ assert cache.get("p", "m", prompt="q", max_tokens=100).content == "short" # type: ignore[union-attr]
120
+ assert cache.get("p", "m", prompt="q", max_tokens=4000).content == "long" # type: ignore[union-attr]
121
+
122
+
123
+ class TestLLMCacheTTL:
124
+ """TTL expiration tests."""
125
+
126
+ def test_expired_entry_returns_none(self) -> None:
127
+ cache = LLMCache(ttl_hours=0) # 0 hours = immediate expiry
128
+ cache.put(LLMResponse(content="old"), "p", "m", prompt="q")
129
+ # Force timestamp to the past
130
+ for key in cache._entries:
131
+ cache._entries[key]["timestamp"] = time.time() - 1
132
+ result = cache.get("p", "m", prompt="q")
133
+ assert result is None
134
+
135
+ def test_non_expired_entry_returned(self) -> None:
136
+ cache = LLMCache(ttl_hours=24)
137
+ cache.put(LLMResponse(content="fresh"), "p", "m", prompt="q")
138
+ result = cache.get("p", "m", prompt="q")
139
+ assert result is not None
140
+ assert result.content == "fresh"
141
+
142
+ def test_ttl_eviction_increments_stats(self) -> None:
143
+ cache = LLMCache(ttl_hours=0)
144
+ cache.put(LLMResponse(content="old"), "p", "m", prompt="q")
145
+ for key in cache._entries:
146
+ cache._entries[key]["timestamp"] = time.time() - 1
147
+ cache.get("p", "m", prompt="q") # triggers eviction
148
+ assert cache.stats.evictions == 1
149
+ assert cache.stats.misses == 1
150
+
151
+
152
+ class TestLLMCacheEviction:
153
+ """Max-entry eviction tests."""
154
+
155
+ def test_evicts_oldest_when_over_max(self) -> None:
156
+ cache = LLMCache(max_entries=2)
157
+ cache.put(LLMResponse(content="a"), "p", "m", prompt="q1")
158
+ time.sleep(0.01) # ensure distinct timestamps
159
+ cache.put(LLMResponse(content="b"), "p", "m", prompt="q2")
160
+ time.sleep(0.01)
161
+ cache.put(LLMResponse(content="c"), "p", "m", prompt="q3")
162
+ # "a" should have been evicted
163
+ assert cache.size == 2
164
+ assert cache.get("p", "m", prompt="q1") is None
165
+ assert cache.get("p", "m", prompt="q2") is not None
166
+ assert cache.get("p", "m", prompt="q3") is not None
167
+
168
+ def test_eviction_stats_tracked(self) -> None:
169
+ cache = LLMCache(max_entries=1)
170
+ cache.put(LLMResponse(content="a"), "p", "m", prompt="q1")
171
+ cache.put(LLMResponse(content="b"), "p", "m", prompt="q2")
172
+ assert cache.stats.evictions >= 1
173
+
174
+
175
+ class TestCacheStats:
176
+ """CacheStats dataclass."""
177
+
178
+ def test_initial_stats(self) -> None:
179
+ stats = CacheStats()
180
+ assert stats.hits == 0
181
+ assert stats.misses == 0
182
+ assert stats.evictions == 0
183
+ assert stats.size == 0
184
+ assert stats.hit_rate == 0.0
185
+
186
+ def test_hit_rate_calculation(self) -> None:
187
+ stats = CacheStats(hits=3, misses=1)
188
+ assert stats.hit_rate == 75.0
189
+
190
+ def test_hit_rate_zero_total(self) -> None:
191
+ stats = CacheStats()
192
+ assert stats.hit_rate == 0.0
193
+
194
+ def test_to_dict(self) -> None:
195
+ stats = CacheStats(hits=5, misses=2, evictions=1, size=10)
196
+ d = stats.to_dict()
197
+ assert d["hits"] == 5
198
+ assert d["misses"] == 2
199
+ assert d["evictions"] == 1
200
+ assert d["size"] == 10
201
+ assert "hit_rate" in d
202
+
203
+ def test_cache_stats_updated_on_hit(self) -> None:
204
+ cache = LLMCache()
205
+ cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
206
+ cache.get("p", "m", prompt="q")
207
+ assert cache.stats.hits == 1
208
+
209
+ def test_cache_stats_updated_on_miss(self) -> None:
210
+ cache = LLMCache()
211
+ cache.get("p", "m", prompt="missing")
212
+ assert cache.stats.misses == 1
213
+
214
+
215
+ class TestLLMCachePersistence:
216
+ """Disk persistence (save/load)."""
217
+
218
+ def test_save_and_load(self, tmp_path: Path) -> None:
219
+ cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
220
+ cache.put(LLMResponse(content="saved", model="m", provider="p"), "p", "m", prompt="q")
221
+ cache.save()
222
+
223
+ cache2 = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
224
+ result = cache2.get("p", "m", prompt="q")
225
+ assert result is not None
226
+ assert result.content == "saved"
227
+
228
+ def test_save_creates_file(self, tmp_path: Path) -> None:
229
+ cache = LLMCache(cache_dir=str(tmp_path))
230
+ cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
231
+ cache.save()
232
+ assert (tmp_path / "llm_cache.json").exists()
233
+
234
+ def test_load_skips_expired(self, tmp_path: Path) -> None:
235
+ # Write a cache file with old timestamps
236
+ data = {
237
+ "abc123": {
238
+ "response": {"content": "old", "model": "", "provider": "", "usage": {}},
239
+ "timestamp": time.time() - 100000,
240
+ "provider": "p",
241
+ "model": "m",
242
+ }
243
+ }
244
+ (tmp_path / "llm_cache.json").write_text(json.dumps(data))
245
+ cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=1)
246
+ assert cache.size == 0 # expired entries not loaded
247
+
248
+ def test_load_invalid_json(self, tmp_path: Path) -> None:
249
+ (tmp_path / "llm_cache.json").write_text("not json!")
250
+ cache = LLMCache(cache_dir=str(tmp_path))
251
+ assert cache.size == 0
252
+
253
+ def test_load_nonexistent_dir(self, tmp_path: Path) -> None:
254
+ cache = LLMCache(cache_dir=str(tmp_path / "nonexistent"))
255
+ assert cache.size == 0
256
+
257
+ def test_no_cache_dir_save_noop(self) -> None:
258
+ cache = LLMCache()
259
+ cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
260
+ cache.save() # Should not raise
261
+
262
+
263
+ class TestLLMCacheKeyDeterminism:
264
+ """Cache key generation is deterministic."""
265
+
266
+ def test_same_input_same_key(self) -> None:
267
+ msgs = [LLMMessage(role=MessageRole.USER, content="hello")]
268
+ k1 = LLMCache._make_key("openai", "gpt-4", msgs, None, 0.2, 2048)
269
+ k2 = LLMCache._make_key("openai", "gpt-4", msgs, None, 0.2, 2048)
270
+ assert k1 == k2
271
+
272
+ def test_different_provider_different_key(self) -> None:
273
+ k1 = LLMCache._make_key("openai", "gpt-4", None, "hi", 0.2, 2048)
274
+ k2 = LLMCache._make_key("ollama", "gpt-4", None, "hi", 0.2, 2048)
275
+ assert k1 != k2
276
+
277
+ def test_different_model_different_key(self) -> None:
278
+ k1 = LLMCache._make_key("openai", "gpt-4", None, "hi", 0.2, 2048)
279
+ k2 = LLMCache._make_key("openai", "gpt-3.5", None, "hi", 0.2, 2048)
280
+ assert k1 != k2
281
+
282
+ def test_key_is_sha256(self) -> None:
283
+ key = LLMCache._make_key("p", "m", None, "prompt", 0.2, 2048)
284
+ assert len(key) == 64 # SHA-256 hex digest length
285
+
286
+
287
+ # ═══════════════════════════════════════════════════════════════════════════
288
+ # 2 — RateLimiter: sliding window enforcement
289
+ # ═══════════════════════════════════════════════════════════════════════════
290
+
291
+
292
+ class TestRateLimiterBasic:
293
+ """Basic rate limiter operations."""
294
+
295
+ def test_unlimited_always_allows(self) -> None:
296
+ rl = RateLimiter(rpm=0, tpm=0)
297
+ rl.acquire() # Should not raise
298
+ assert not rl.is_enabled
299
+
300
+ def test_is_enabled_rpm(self) -> None:
301
+ rl = RateLimiter(rpm=10)
302
+ assert rl.is_enabled
303
+
304
+ def test_is_enabled_tpm(self) -> None:
305
+ rl = RateLimiter(tpm=1000)
306
+ assert rl.is_enabled
307
+
308
+ def test_acquire_within_limit(self) -> None:
309
+ rl = RateLimiter(rpm=100, blocking=False)
310
+ rl.acquire() # Should not raise
311
+
312
+ def test_acquire_exceeds_rpm_nonblocking(self) -> None:
313
+ rl = RateLimiter(rpm=2, blocking=False)
314
+ rl.acquire()
315
+ rl.acquire()
316
+ with pytest.raises(RateLimitExceeded):
317
+ rl.acquire()
318
+
319
+ def test_record_usage(self) -> None:
320
+ rl = RateLimiter(rpm=100)
321
+ rl.acquire()
322
+ rl.record_usage(500)
323
+ assert rl.stats.total_tokens == 500
324
+
325
+ def test_stats_tracking(self) -> None:
326
+ rl = RateLimiter(rpm=100)
327
+ rl.acquire()
328
+ rl.acquire()
329
+ assert rl.stats.total_requests == 2
330
+
331
+
332
+ class TestRateLimiterTPM:
333
+ """Tokens-per-minute enforcement."""
334
+
335
+ def test_tpm_limit_nonblocking(self) -> None:
336
+ rl = RateLimiter(tpm=100, blocking=False)
337
+ rl.acquire(estimated_tokens=80)
338
+ with pytest.raises(RateLimitExceeded):
339
+ rl.acquire(estimated_tokens=80)
340
+
341
+ def test_tpm_allows_within_limit(self) -> None:
342
+ rl = RateLimiter(tpm=1000, blocking=False)
343
+ rl.acquire(estimated_tokens=400)
344
+ rl.acquire(estimated_tokens=400) # 800 total, within 1000
345
+
346
+ def test_tpm_stats_current(self) -> None:
347
+ rl = RateLimiter(tpm=10000)
348
+ rl.acquire(estimated_tokens=500)
349
+ assert rl.stats.current_tpm == 500
350
+
351
+
352
+ class TestRateLimitExceeded:
353
+ """RateLimitExceeded exception."""
354
+
355
+ def test_exception_message(self) -> None:
356
+ exc = RateLimitExceeded("too fast")
357
+ assert str(exc) == "too fast"
358
+
359
+ def test_retry_after(self) -> None:
360
+ exc = RateLimitExceeded("slow down", retry_after=5.0)
361
+ assert exc.retry_after == 5.0
362
+
363
+ def test_default_retry_after(self) -> None:
364
+ exc = RateLimitExceeded()
365
+ assert exc.retry_after == 0.0
366
+
367
+ def test_rejected_requests_tracked(self) -> None:
368
+ rl = RateLimiter(rpm=1, blocking=False)
369
+ rl.acquire()
370
+ with pytest.raises(RateLimitExceeded):
371
+ rl.acquire()
372
+ assert rl.stats.rejected_requests == 1
373
+
374
+
375
+ class TestRateLimiterStats:
376
+ """RateLimiterStats dataclass."""
377
+
378
+ def test_initial_stats(self) -> None:
379
+ stats = RateLimiterStats()
380
+ assert stats.total_requests == 0
381
+ assert stats.total_tokens == 0
382
+ assert stats.rejected_requests == 0
383
+
384
+ def test_to_dict(self) -> None:
385
+ stats = RateLimiterStats(total_requests=5, total_tokens=1000)
386
+ d = stats.to_dict()
387
+ assert d["total_requests"] == 5
388
+ assert d["total_tokens"] == 1000
389
+ assert "current_rpm" in d
390
+ assert "current_tpm" in d
391
+
392
+
393
+ class TestRateLimiterSlidingWindow:
394
+ """Sliding window prune behaviour."""
395
+
396
+ def test_old_events_pruned(self) -> None:
397
+ rl = RateLimiter(rpm=2, blocking=False)
398
+ rl.acquire()
399
+ rl.acquire()
400
+ # Manually age the events
401
+ for ev in rl._events:
402
+ ev.timestamp -= 61.0
403
+ rl.acquire() # Should succeed because old events were pruned
404
+
405
+
406
+ # ═══════════════════════════════════════════════════════════════════════════
407
+ # 3 — CachedProvider: wrapper for any LLMProvider
408
+ # ═══════════════════════════════════════════════════════════════════════════
409
+
410
+
411
+ class TestCachedProviderBasic:
412
+ """CachedProvider wraps an LLMProvider with caching and rate limiting."""
413
+
414
+ def _make_mock(self, content: str = "mock response") -> MockProvider:
415
+ provider = MockProvider()
416
+ provider.enqueue_response(content)
417
+ return provider
418
+
419
+ def test_name_delegates_to_inner(self) -> None:
420
+ mock = self._make_mock()
421
+ cp = CachedProvider(mock)
422
+ assert cp.name == mock.name
423
+
424
+ def test_is_available_delegates(self) -> None:
425
+ mock = self._make_mock()
426
+ cp = CachedProvider(mock)
427
+ assert cp.is_available() == mock.is_available()
428
+
429
+ def test_inner_property(self) -> None:
430
+ mock = self._make_mock()
431
+ cp = CachedProvider(mock)
432
+ assert cp.inner is mock
433
+
434
+ def test_complete_without_cache(self) -> None:
435
+ mock = self._make_mock("hello")
436
+ cp = CachedProvider(mock)
437
+ result = cp.complete("test prompt")
438
+ assert result.content == "hello"
439
+
440
+ def test_chat_without_cache(self) -> None:
441
+ mock = self._make_mock("hi there")
442
+ cp = CachedProvider(mock)
443
+ msgs = [LLMMessage(role=MessageRole.USER, content="say hi")]
444
+ result = cp.chat(msgs)
445
+ assert result.content == "hi there"
446
+
447
+
448
+ class TestCachedProviderCaching:
449
+ """Caching behaviour through CachedProvider."""
450
+
451
+ def _make_provider(self) -> tuple[MockProvider, CachedProvider]:
452
+ mock = MockProvider()
453
+ mock.enqueue_response("first")
454
+ mock.enqueue_response("second")
455
+ cache = LLMCache(ttl_hours=24)
456
+ return mock, CachedProvider(mock, cache=cache)
457
+
458
+ def test_complete_caches_response(self) -> None:
459
+ mock, cp = self._make_provider()
460
+ r1 = cp.complete("query")
461
+ r2 = cp.complete("query")
462
+ assert r1.content == "first"
463
+ assert r2.content == "first" # same, from cache
464
+
465
+ def test_chat_caches_response(self) -> None:
466
+ mock, cp = self._make_provider()
467
+ msgs = [LLMMessage(role=MessageRole.USER, content="hello")]
468
+ r1 = cp.chat(msgs)
469
+ r2 = cp.chat(msgs)
470
+ assert r1.content == "first"
471
+ assert r2.content == "first" # cached
472
+
473
+ def test_different_prompts_not_cached(self) -> None:
474
+ mock, cp = self._make_provider()
475
+ r1 = cp.complete("query1")
476
+ r2 = cp.complete("query2")
477
+ assert r1.content == "first"
478
+ assert r2.content == "second" # different prompt → different result
479
+
480
+ def test_save_cache_delegates(self, tmp_path: Path) -> None:
481
+ mock = MockProvider()
482
+ mock.enqueue_response("data")
483
+ cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
484
+ cp = CachedProvider(mock, cache=cache)
485
+ cp.complete("test")
486
+ cp.save_cache()
487
+ assert (tmp_path / "llm_cache.json").exists()
488
+
489
+ def test_save_cache_noop_without_cache(self) -> None:
490
+ mock = MockProvider()
491
+ cp = CachedProvider(mock)
492
+ cp.save_cache() # Should not raise
493
+
494
+
495
+ class TestCachedProviderRateLimiting:
496
+ """Rate limiting through CachedProvider."""
497
+
498
+ def test_rate_limited_requests(self) -> None:
499
+ mock = MockProvider()
500
+ for r in ["a", "b", "c"]:
501
+ mock.enqueue_response(r)
502
+ rl = RateLimiter(rpm=2, blocking=False)
503
+ cp = CachedProvider(mock, rate_limiter=rl)
504
+ cp.complete("q1")
505
+ cp.complete("q2")
506
+ with pytest.raises(RateLimitExceeded):
507
+ cp.complete("q3")
508
+
509
+ def test_rate_limiter_records_usage(self) -> None:
510
+ mock = MockProvider()
511
+ mock.enqueue_response("result")
512
+ rl = RateLimiter(rpm=100)
513
+ cp = CachedProvider(mock, rate_limiter=rl)
514
+ cp.complete("test")
515
+ assert rl.stats.total_requests == 1
516
+
517
+ def test_cached_response_skips_rate_limit(self) -> None:
518
+ mock = MockProvider()
519
+ mock.enqueue_response("first")
520
+ mock.enqueue_response("second")
521
+ cache = LLMCache(ttl_hours=24)
522
+ rl = RateLimiter(rpm=1, blocking=False)
523
+ cp = CachedProvider(mock, cache=cache, rate_limiter=rl)
524
+ cp.complete("same-query") # Uses rate limit slot
525
+ cp.complete("same-query") # Cache hit — no rate limit needed
526
+
527
+
528
+ class TestCachedProviderChat:
529
+ """Chat-specific caching and rate limiting."""
530
+
531
+ def test_chat_cache_and_rate_limit_combined(self) -> None:
532
+ mock = MockProvider()
533
+ mock.enqueue_response("resp1")
534
+ mock.enqueue_response("resp2")
535
+ cache = LLMCache(ttl_hours=24)
536
+ rl = RateLimiter(rpm=100)
537
+ cp = CachedProvider(mock, cache=cache, rate_limiter=rl)
538
+ msgs = [LLMMessage(role=MessageRole.USER, content="hi")]
539
+ r1 = cp.chat(msgs)
540
+ r2 = cp.chat(msgs)
541
+ assert r1.content == "resp1"
542
+ assert r2.content == "resp1" # from cache
543
+ assert rl.stats.total_requests == 1 # only one actual API call
544
+
545
+
546
+ # ═══════════════════════════════════════════════════════════════════════════
547
+ # 4 — LLMConfig: new cache/rate-limit fields
548
+ # ═══════════════════════════════════════════════════════════════════════════
549
+
550
+
551
+ class TestLLMConfigFields:
552
+ """LLMConfig has cache and rate limit configuration fields."""
553
+
554
+ def test_cache_enabled_default(self) -> None:
555
+ from semantic_code_intelligence.config.settings import LLMConfig
556
+ cfg = LLMConfig()
557
+ assert cfg.cache_enabled is True
558
+
559
+ def test_cache_ttl_hours_default(self) -> None:
560
+ from semantic_code_intelligence.config.settings import LLMConfig
561
+ cfg = LLMConfig()
562
+ assert cfg.cache_ttl_hours == 24
563
+
564
+ def test_cache_max_entries_default(self) -> None:
565
+ from semantic_code_intelligence.config.settings import LLMConfig
566
+ cfg = LLMConfig()
567
+ assert cfg.cache_max_entries == 1000
568
+
569
+ def test_rate_limit_rpm_default(self) -> None:
570
+ from semantic_code_intelligence.config.settings import LLMConfig
571
+ cfg = LLMConfig()
572
+ assert cfg.rate_limit_rpm == 0
573
+
574
+ def test_rate_limit_tpm_default(self) -> None:
575
+ from semantic_code_intelligence.config.settings import LLMConfig
576
+ cfg = LLMConfig()
577
+ assert cfg.rate_limit_tpm == 0
578
+
579
+ def test_custom_values(self) -> None:
580
+ from semantic_code_intelligence.config.settings import LLMConfig
581
+ cfg = LLMConfig(
582
+ cache_enabled=False,
583
+ cache_ttl_hours=48,
584
+ cache_max_entries=500,
585
+ rate_limit_rpm=30,
586
+ rate_limit_tpm=50000,
587
+ )
588
+ assert cfg.cache_enabled is False
589
+ assert cfg.cache_ttl_hours == 48
590
+ assert cfg.cache_max_entries == 500
591
+ assert cfg.rate_limit_rpm == 30
592
+ assert cfg.rate_limit_tpm == 50000
593
+
594
+ def test_serialization_roundtrip(self) -> None:
595
+ from semantic_code_intelligence.config.settings import LLMConfig
596
+ cfg = LLMConfig(cache_enabled=True, rate_limit_rpm=60)
597
+ data = json.loads(cfg.model_dump_json())
598
+ restored = LLMConfig.model_validate(data)
599
+ assert restored.cache_enabled is True
600
+ assert restored.rate_limit_rpm == 60
601
+
602
+ def test_config_in_appconfig(self) -> None:
603
+ from semantic_code_intelligence.config.settings import AppConfig
604
+ app = AppConfig()
605
+ assert hasattr(app.llm, "cache_enabled")
606
+ assert hasattr(app.llm, "rate_limit_rpm")
607
+ assert hasattr(app.llm, "rate_limit_tpm")
608
+
609
+ def test_config_json_persistence(self, tmp_path: Path) -> None:
610
+ from semantic_code_intelligence.config.settings import AppConfig, save_config, load_config
611
+ cfg = AppConfig(project_root=str(tmp_path))
612
+ cfg.llm.cache_enabled = False
613
+ cfg.llm.rate_limit_rpm = 42
614
+ save_config(cfg, tmp_path)
615
+ loaded = load_config(tmp_path)
616
+ assert loaded.llm.cache_enabled is False
617
+ assert loaded.llm.rate_limit_rpm == 42
618
+
619
+
620
+ # ═══════════════════════════════════════════════════════════════════════════
621
+ # 5 — CLI _wrap_provider integration
622
+ # ═══════════════════════════════════════════════════════════════════════════
623
+
624
+
625
+ class TestWrapProviderIntegration:
626
+ """_wrap_provider correctly builds CachedProvider from config."""
627
+
628
+ def _make_config(
629
+ self,
630
+ cache_enabled: bool = True,
631
+ rpm: int = 0,
632
+ tpm: int = 0,
633
+ ) -> Any:
634
+ from semantic_code_intelligence.config.settings import AppConfig, LLMConfig
635
+ cfg = AppConfig()
636
+ cfg.llm.cache_enabled = cache_enabled
637
+ cfg.llm.rate_limit_rpm = rpm
638
+ cfg.llm.rate_limit_tpm = tpm
639
+ return cfg
640
+
641
+ def test_wrap_returns_cached_provider(self) -> None:
642
+ from semantic_code_intelligence.cli.commands.ask_cmd import _wrap_provider
643
+ cfg = self._make_config(cache_enabled=True)
644
+ provider = MockProvider()
645
+ result = _wrap_provider(provider, cfg.llm, cfg)
646
+ assert isinstance(result, CachedProvider)
647
+
648
+ def test_wrap_no_cache_no_rate_limit(self) -> None:
649
+ from semantic_code_intelligence.cli.commands.ask_cmd import _wrap_provider
650
+ cfg = self._make_config(cache_enabled=False, rpm=0, tpm=0)
651
+ provider = MockProvider()
652
+ result = _wrap_provider(provider, cfg.llm, cfg)
653
+ assert result is provider # No wrapping
654
+
655
+ def test_wrap_rate_limit_only(self) -> None:
656
+ from semantic_code_intelligence.cli.commands.ask_cmd import _wrap_provider
657
+ cfg = self._make_config(cache_enabled=False, rpm=60)
658
+ provider = MockProvider()
659
+ result = _wrap_provider(provider, cfg.llm, cfg)
660
+ assert isinstance(result, CachedProvider)
661
+
662
+ def test_wrap_chat_cmd(self) -> None:
663
+ from semantic_code_intelligence.cli.commands.chat_cmd import _wrap_provider
664
+ cfg = self._make_config(cache_enabled=True)
665
+ provider = MockProvider()
666
+ result = _wrap_provider(provider, cfg.llm, cfg)
667
+ assert isinstance(result, CachedProvider)
668
+
669
+ def test_wrap_investigate_cmd(self) -> None:
670
+ from semantic_code_intelligence.cli.commands.investigate_cmd import _wrap_provider
671
+ cfg = self._make_config(cache_enabled=True)
672
+ provider = MockProvider()
673
+ result = _wrap_provider(provider, cfg.llm, cfg)
674
+ assert isinstance(result, CachedProvider)
675
+
676
+
677
+ # ═══════════════════════════════════════════════════════════════════════════
678
+ # 6 — Module exports and imports
679
+ # ═══════════════════════════════════════════════════════════════════════════
680
+
681
+
682
+ class TestModuleExports:
683
+ """Verify new classes are exported from the llm package."""
684
+
685
+ def test_cache_exported(self) -> None:
686
+ from semantic_code_intelligence.llm import LLMCache
687
+ assert LLMCache is not None
688
+
689
+ def test_cache_stats_exported(self) -> None:
690
+ from semantic_code_intelligence.llm import CacheStats
691
+ assert CacheStats is not None
692
+
693
+ def test_cached_provider_exported(self) -> None:
694
+ from semantic_code_intelligence.llm import CachedProvider
695
+ assert CachedProvider is not None
696
+
697
+ def test_rate_limiter_exported(self) -> None:
698
+ from semantic_code_intelligence.llm import RateLimiter
699
+ assert RateLimiter is not None
700
+
701
+ def test_rate_limit_exceeded_exported(self) -> None:
702
+ from semantic_code_intelligence.llm import RateLimitExceeded
703
+ assert RateLimitExceeded is not None
704
+
705
+ def test_rate_limiter_stats_exported(self) -> None:
706
+ from semantic_code_intelligence.llm import RateLimiterStats
707
+ assert RateLimiterStats is not None
708
+
709
+ def test_all_list_includes_new_classes(self) -> None:
710
+ import semantic_code_intelligence.llm as llm_mod
711
+ for name in ["CachedProvider", "LLMCache", "CacheStats",
712
+ "RateLimiter", "RateLimitExceeded", "RateLimiterStats"]:
713
+ assert name in llm_mod.__all__
714
+
715
+
716
+ # ═══════════════════════════════════════════════════════════════════════════
717
+ # 7 — End-to-end flow
718
+ # ═══════════════════════════════════════════════════════════════════════════
719
+
720
+
721
+ class TestEndToEndFlow:
722
+ """Full caching + rate limiting pipeline."""
723
+
724
+ def test_full_flow_complete(self) -> None:
725
+ mock = MockProvider()
726
+ mock.enqueue_response("answer1")
727
+ mock.enqueue_response("answer2")
728
+ cache = LLMCache(ttl_hours=24)
729
+ rl = RateLimiter(rpm=100)
730
+ cp = CachedProvider(mock, cache=cache, rate_limiter=rl)
731
+
732
+ # First call — cache miss, hits provider
733
+ r1 = cp.complete("what is 2+2?")
734
+ assert r1.content == "answer1"
735
+ assert cache.stats.misses == 1
736
+ assert cache.stats.hits == 0
737
+
738
+ # Second call — cache hit
739
+ r2 = cp.complete("what is 2+2?")
740
+ assert r2.content == "answer1"
741
+ assert cache.stats.hits == 1
742
+
743
+ # Different query — cache miss
744
+ r3 = cp.complete("what is 3+3?")
745
+ assert r3.content == "answer2"
746
+ assert cache.stats.misses == 2
747
+
748
+ def test_full_flow_chat(self) -> None:
749
+ mock = MockProvider()
750
+ mock.enqueue_response("reply1")
751
+ mock.enqueue_response("reply2")
752
+ cache = LLMCache(ttl_hours=24)
753
+ cp = CachedProvider(mock, cache=cache)
754
+
755
+ msgs = [LLMMessage(role=MessageRole.USER, content="greet me")]
756
+ r1 = cp.chat(msgs)
757
+ r2 = cp.chat(msgs)
758
+ assert r1.content == "reply1"
759
+ assert r2.content == "reply1"
760
+
761
+ def test_full_flow_with_persistence(self, tmp_path: Path) -> None:
762
+ mock = MockProvider()
763
+ mock.enqueue_response("persisted")
764
+ cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
765
+ cp = CachedProvider(mock, cache=cache)
766
+ cp.complete("save me")
767
+ cp.save_cache()
768
+
769
+ # New provider instance loads from disk
770
+ cache2 = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
771
+ cp2 = CachedProvider(MockProvider(), cache=cache2)
772
+ result = cp2.complete("save me")
773
+ assert result is not None
774
+ assert result.content == "persisted"
775
+
776
+ def test_rate_limit_protects_provider(self) -> None:
777
+ mock = MockProvider()
778
+ for r in ["a", "b", "c", "d", "e"]:
779
+ mock.enqueue_response(r)
780
+ rl = RateLimiter(rpm=3, blocking=False)
781
+ cp = CachedProvider(mock, rate_limiter=rl)
782
+ cp.complete("q1")
783
+ cp.complete("q2")
784
+ cp.complete("q3")
785
+ with pytest.raises(RateLimitExceeded):
786
+ cp.complete("q4")
787
+
788
+
789
+ # ═══════════════════════════════════════════════════════════════════════════
790
+ # 8 — Version check
791
+ # ═══════════════════════════════════════════════════════════════════════════
792
+
793
+
794
+ class TestVersion:
795
+ """Version should reflect Phase 22."""
796
+
797
+ def test_version_is_0_22_0(self) -> None:
798
+ from semantic_code_intelligence import __version__
799
+ assert __version__ == "0.4.0"