codegraph-voyage 0.1.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.
@@ -0,0 +1,1114 @@
1
+ """Tests for codegraph-voyage — document construction, sanitization, sidecar,
2
+ ranking, providers, and CLI integration."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import io
8
+ import email.message
9
+ import os
10
+ import sqlite3
11
+ import tempfile
12
+ import unittest
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from unittest import mock
18
+
19
+
20
+ # =========================================================================
21
+ # Document construction
22
+ # =========================================================================
23
+
24
+ class TestDocumentConstruction(unittest.TestCase):
25
+ """build_document, build_documents_from_db, build_document_for_node_id."""
26
+
27
+ def setUp(self):
28
+ self.root = Path(tempfile.mkdtemp())
29
+ # Create a source file
30
+ self.src = self.root / "src" / "example.py"
31
+ self.src.parent.mkdir(parents=True)
32
+ self.src.write_text("""def hello():
33
+ \"\"\"Say hello.\"\"\"
34
+ return "hello"
35
+
36
+ class Greeter:
37
+ \"\"\"A greeter class.\"\"\"
38
+ def greet(self, name: str) -> str:
39
+ return f"Hello {name}"
40
+ """)
41
+
42
+ def tearDown(self):
43
+ # Clean up temp dir
44
+ import shutil
45
+ shutil.rmtree(str(self.root), ignore_errors=True)
46
+
47
+ def _make_node(self, **overrides: Any) -> dict[str, Any]:
48
+ node = {
49
+ "name": "hello",
50
+ "qualified_name": "example.hello",
51
+ "kind": "function",
52
+ "file_path": "src/example.py",
53
+ "language": "python",
54
+ "start_line": 1,
55
+ "end_line": 3,
56
+ "docstring": "Say hello.",
57
+ "signature": "def hello()",
58
+ "visibility": "public",
59
+ "return_type": "str",
60
+ }
61
+ node.update(overrides)
62
+ return node
63
+
64
+ def test_build_document_basic(self):
65
+ from tools.codegraph_voyage.document import build_document
66
+ doc = build_document(self._make_node(), self.root)
67
+ self.assertIn("Symbol: hello", doc)
68
+ self.assertIn("Qualified Name: example.hello", doc)
69
+ self.assertIn("Kind: function", doc)
70
+ self.assertIn("Say hello.", doc)
71
+ self.assertIn('def hello():', doc)
72
+ self.assertIn('return "hello"', doc)
73
+
74
+ def test_build_document_no_source(self):
75
+ from tools.codegraph_voyage.document import build_document
76
+ doc = build_document(self._make_node(), self.root, include_source=False)
77
+ self.assertIn("Symbol: hello", doc)
78
+ self.assertNotIn('def hello():', doc)
79
+
80
+ def test_build_document_max_lines(self):
81
+ from tools.codegraph_voyage.document import build_document
82
+ doc = build_document(self._make_node(), self.root, max_source_lines=1)
83
+ self.assertIn("truncated at 1 lines", doc)
84
+
85
+ def test_build_document_missing_file(self):
86
+ from tools.codegraph_voyage.document import build_document
87
+ node = self._make_node(file_path="nonexistent.py")
88
+ doc = build_document(node, self.root)
89
+ self.assertIn("Symbol: hello", doc)
90
+ # No source lines, no error
91
+ self.assertNotIn("Source:", doc)
92
+
93
+ def test_source_path_cannot_escape_root(self):
94
+ from tools.codegraph_voyage.document import build_document
95
+ outside = self.root.parent / "outside-codegraph-voyage.txt"
96
+ outside.write_text("must-not-be-embedded")
97
+ try:
98
+ node = self._make_node(file_path="../outside-codegraph-voyage.txt")
99
+ doc = build_document(node, self.root)
100
+ self.assertNotIn("must-not-be-embedded", doc)
101
+ self.assertNotIn("Source:", doc)
102
+ finally:
103
+ outside.unlink(missing_ok=True)
104
+
105
+ def test_compute_content_hash(self):
106
+ from tools.codegraph_voyage.document import compute_content_hash
107
+ h1 = compute_content_hash("hello world")
108
+ h2 = compute_content_hash("hello world")
109
+ h3 = compute_content_hash("hello world!")
110
+ self.assertEqual(h1, h2)
111
+ self.assertNotEqual(h1, h3)
112
+
113
+ def test_build_documents_from_db_empty(self):
114
+ """Table doesn't exist yet — should raise or handle gracefully."""
115
+ from tools.codegraph_voyage.document import build_documents_from_db, DocumentConstructionError
116
+ fake_db = self.root / "codegraph.db"
117
+ # Not a valid SQLite DB
118
+ fake_db.write_text("not a database")
119
+ with self.assertRaises((sqlite3.DatabaseError, DocumentConstructionError)):
120
+ build_documents_from_db(fake_db, self.root)
121
+
122
+
123
+ # =========================================================================
124
+ # Sanitization
125
+ # =========================================================================
126
+
127
+ class TestSanitization(unittest.TestCase):
128
+ """is_sensitive_path and sanitize_content."""
129
+
130
+ def test_sensitive_paths(self):
131
+ from tools.codegraph_voyage.sanitize import is_sensitive_path
132
+ self.assertTrue(is_sensitive_path(".env"))
133
+ self.assertTrue(is_sensitive_path("config/.env.production"))
134
+ self.assertTrue(is_sensitive_path("credentials/aws.json"))
135
+ self.assertTrue(is_sensitive_path("secrets/keys.yaml"))
136
+ self.assertTrue(is_sensitive_path(".git/config"))
137
+ self.assertTrue(is_sensitive_path("src/__pycache__/foo.pyc"))
138
+ self.assertTrue(is_sensitive_path("node_modules/bar.js"))
139
+ self.assertTrue(is_sensitive_path(r"home\.aws\credentials"))
140
+ self.assertTrue(is_sensitive_path(".ssh/id_ed25519"))
141
+ self.assertTrue(is_sensitive_path(".docker/config.json"))
142
+ self.assertTrue(is_sensitive_path("config/service-account-prod.json"))
143
+ self.assertTrue(is_sensitive_path(".git-credentials"))
144
+ self.assertTrue(is_sensitive_path("deploy_id_rsa"))
145
+ self.assertFalse(is_sensitive_path("pyproject.toml"))
146
+ self.assertFalse(is_sensitive_path("src/main.py"))
147
+ self.assertFalse(is_sensitive_path("README.md"))
148
+
149
+ def test_sanitize_content(self):
150
+ from tools.codegraph_voyage.sanitize import sanitize_content
151
+ content = "public code\npassword = 'supersecret'\nmore public code"
152
+ result = sanitize_content(content, "src/main.py")
153
+ self.assertIn("public code", result)
154
+ self.assertIn("[redacted", result)
155
+ self.assertIn("more public code", result)
156
+ self.assertNotIn("supersecret", result)
157
+
158
+ def test_sanitize_content_sensitive_path(self):
159
+ from tools.codegraph_voyage.sanitize import sanitize_content
160
+ result = sanitize_content("anything", ".env")
161
+ self.assertEqual(result, "[content excluded: sensitive path]")
162
+
163
+ def test_sanitize_content_all_redacted(self):
164
+ from tools.codegraph_voyage.sanitize import sanitize_content
165
+ # Use a value >= 8 chars to match the {8,} pattern
166
+ result = sanitize_content("password = 'hunter2!!'", "src/main.py")
167
+ # The line is replaced with a redacted marker, not removed
168
+ self.assertIn("[redacted", result)
169
+
170
+ def test_structured_secret_assignments_are_redacted(self):
171
+ from tools.codegraph_voyage.sanitize import sanitize_content
172
+ secret = "live-secret-938475"
173
+ content = "\n".join([
174
+ f'{{"password": "{secret}"}}',
175
+ f"client_secret: {secret}",
176
+ f'api_key = "{secret}"',
177
+ f"auth_token={secret}",
178
+ f"aws_secret_access_key = {secret}",
179
+ ])
180
+ result = sanitize_content(content, "config/settings.toml")
181
+ self.assertNotIn(secret, result)
182
+ self.assertEqual(result.count("[redacted"), 5)
183
+
184
+ def test_placeholders_are_not_redacted(self):
185
+ from tools.codegraph_voyage.sanitize import sanitize_content
186
+ content = 'password = "placeholder"\ntoken: ${TOKEN}'
187
+ self.assertEqual(sanitize_content(content, "config/example.yaml"), content)
188
+
189
+ def test_private_key_block_is_redacted(self):
190
+ from tools.codegraph_voyage.sanitize import sanitize_content
191
+ begin_marker = "-----BEGIN " + "OPENSSH PRIVATE KEY" + "-----"
192
+ end_marker = "-----END " + "OPENSSH PRIVATE KEY" + "-----"
193
+ secret = "SECRETKEYDATA"
194
+ content = f"{begin_marker}\n{secret}\n{end_marker}"
195
+ result = sanitize_content(content, "src/example.txt")
196
+ self.assertNotIn(secret, result)
197
+
198
+ def test_sanitized_outbound_payload_has_no_secret(self):
199
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
200
+ from tools.codegraph_voyage.sanitize import sanitize_content
201
+ secret = "voyage-must-never-see-this"
202
+ sanitized = sanitize_content(f'{{"api_key": "{secret}"}}', "config.json")
203
+ provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
204
+ response = json.dumps({"data": [{"index": 0, "embedding": [0.1, 0.2]}]}).encode()
205
+ with mock.patch("urllib.request.urlopen") as mocked:
206
+ mocked.return_value.__enter__.return_value.read.return_value = response
207
+ provider.embed_documents([sanitized])
208
+ payload = mocked.call_args[0][0].data.decode()
209
+ self.assertNotIn(secret, payload)
210
+ self.assertIn("redacted", payload)
211
+
212
+
213
+ # =========================================================================
214
+ # Embedding providers
215
+ # =========================================================================
216
+
217
+ class TestProviders(unittest.TestCase):
218
+ """FakeEmbeddingProvider determinism, VoyageEmbeddingProvider request
219
+ construction via mocked urllib."""
220
+
221
+ def test_fake_determinism(self):
222
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider
223
+ p = FakeEmbeddingProvider(dimensions=8)
224
+ texts = ["hello", "world"]
225
+ emb1 = p.embed_documents(texts)
226
+ emb2 = p.embed_documents(texts)
227
+ self.assertEqual(emb1, emb2)
228
+ self.assertEqual(len(emb1), 2)
229
+ self.assertEqual(len(emb1[0]), 8)
230
+ self.assertEqual(len(emb1[1]), 8)
231
+ # Different texts → different embeddings
232
+ emb3 = p.embed_documents(["hello", "goodbye"])
233
+ self.assertEqual(emb1[0], emb3[0]) # same text, same seed → same
234
+
235
+ def test_fake_query_vs_document(self):
236
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider
237
+ p = FakeEmbeddingProvider(dimensions=4)
238
+ q = p.embed_query("test")
239
+ d = p.embed_documents(["test"])[0]
240
+ # Different input_type → different seed → different embedding
241
+ self.assertNotEqual(q, d)
242
+
243
+ def test_fake_properties(self):
244
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider
245
+ p = FakeEmbeddingProvider(dimensions=256)
246
+ self.assertEqual(p.model_name, "fake-embedding-v1")
247
+ self.assertEqual(p.dimensions, 256)
248
+
249
+ def test_raw_bytes_roundtrip(self):
250
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider
251
+ p = FakeEmbeddingProvider(dimensions=8)
252
+ vec = p._derive("test")
253
+ blob = p.raw_bytes(vec)
254
+ restored = p.from_bytes(blob)
255
+ self.assertAlmostEqual(vec[0], restored[0], places=6)
256
+ self.assertEqual(len(vec), len(restored))
257
+
258
+ def test_voyage_requires_key(self):
259
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
260
+ with mock.patch.dict("os.environ", {"VOYAGE_API_KEY": ""}):
261
+ with self.assertRaises(ValueError):
262
+ VoyageEmbeddingProvider(api_key="")
263
+
264
+ def test_voyage_mocked_request(self):
265
+ """Mock urllib.request to verify input_type payload."""
266
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
267
+
268
+ provider = VoyageEmbeddingProvider(api_key="test-key", model="voyage-code-4", dimensions=512)
269
+
270
+ fake_response = json.dumps({
271
+ "data": [
272
+ {"index": 0, "embedding": [0.1] * 512},
273
+ {"index": 1, "embedding": [0.2] * 512},
274
+ ]
275
+ }).encode("utf-8")
276
+
277
+ with mock.patch("urllib.request.urlopen") as mock_urlopen:
278
+ mock_urlopen.return_value.__enter__.return_value.read.return_value = fake_response
279
+
280
+ result = provider.embed_documents(["hello", "world"], input_type="document")
281
+
282
+ # Verify payload was sent correctly
283
+ call_args = mock_urlopen.call_args
284
+ req: urllib.request.Request = call_args[0][0]
285
+ self.assertIsInstance(req, urllib.request.Request)
286
+ payload = json.loads(req.data)
287
+ self.assertEqual(payload["input_type"], "document")
288
+ self.assertEqual(payload["model"], "voyage-code-4")
289
+ self.assertEqual(payload["input"], ["hello", "world"])
290
+ self.assertEqual(payload["output_dimension"], 512)
291
+ self.assertEqual(req.headers["Authorization"], "Bearer test-key")
292
+
293
+ # Verify result
294
+ self.assertEqual(len(result), 2)
295
+ self.assertEqual(len(result[0]), 512)
296
+ self.assertEqual(result[0][0], 0.1)
297
+
298
+ def test_voyage_query_uses_query_input_type(self):
299
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
300
+ provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
301
+ fake_response = json.dumps({
302
+ "data": [{"index": 0, "embedding": [0.4, 0.6]}]
303
+ }).encode("utf-8")
304
+ with mock.patch("urllib.request.urlopen") as mock_urlopen:
305
+ mock_urlopen.return_value.__enter__.return_value.read.return_value = fake_response
306
+ result = provider.embed_query("find auth", input_type="query")
307
+ payload = json.loads(mock_urlopen.call_args[0][0].data)
308
+ self.assertEqual(payload["input_type"], "query")
309
+ self.assertEqual(payload["input"], ["find auth"])
310
+ self.assertEqual(result, [0.4, 0.6])
311
+
312
+ def test_voyage_empty_texts(self):
313
+ """Empty texts should return empty lists without API call."""
314
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
315
+ provider = VoyageEmbeddingProvider(api_key="test-key")
316
+ with mock.patch("urllib.request.urlopen") as mock_urlopen:
317
+ result = provider.embed_documents(["", " "])
318
+ mock_urlopen.assert_not_called()
319
+ # Should return empty lists for each
320
+ self.assertEqual(len(result), 2)
321
+
322
+ def test_voyage_mixed_empty_and_valid(self):
323
+ """Mix of empty and valid texts — only valid ones sent to API."""
324
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
325
+ provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=4)
326
+
327
+ fake_response = json.dumps({
328
+ "data": [
329
+ {"index": 0, "embedding": [0.1, 0.2, 0.3, 0.4]},
330
+ {"index": 1, "embedding": [0.5, 0.6, 0.7, 0.8]},
331
+ ]
332
+ }).encode("utf-8")
333
+
334
+ with mock.patch("urllib.request.urlopen") as mock_urlopen:
335
+ mock_urlopen.return_value.__enter__.return_value.read.return_value = fake_response
336
+
337
+ texts = ["hello", "", "world"]
338
+ result = provider.embed_documents(texts, input_type="document")
339
+
340
+ # Verify only non-empty texts sent
341
+ payload = json.loads(mock_urlopen.call_args[0][0].data)
342
+ self.assertEqual(payload["input"], ["hello", "world"])
343
+
344
+ # Verify mapping back
345
+ self.assertEqual(len(result), 3)
346
+ self.assertEqual(len(result[0]), 4) # "hello" embedding
347
+ self.assertEqual(result[1], []) # empty text → empty list
348
+ self.assertEqual(len(result[2]), 4) # "world" embedding
349
+
350
+ def test_voyage_http_error_no_body_in_message(self):
351
+ """HTTP error should not include response body in error message."""
352
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
353
+ provider = VoyageEmbeddingProvider(api_key="test-key")
354
+
355
+ with mock.patch("urllib.request.urlopen") as mock_urlopen:
356
+ mock_urlopen.side_effect = urllib.error.HTTPError(
357
+ url="https://api.voyageai.com/v1/embeddings",
358
+ code=401,
359
+ msg="Unauthorized",
360
+ hdrs={},
361
+ fp=None,
362
+ )
363
+ with self.assertRaises(RuntimeError) as ctx:
364
+ provider.embed_documents(["test"], input_type="document")
365
+ self.assertNotIn("sensitive", str(ctx.exception).lower())
366
+ self.assertIn("401", str(ctx.exception))
367
+
368
+ def test_voyage_batches_more_than_128_in_order(self):
369
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
370
+ provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
371
+
372
+ def response_for(request, timeout=None):
373
+ inputs = json.loads(request.data)["input"]
374
+ response = mock.MagicMock()
375
+ response.__enter__.return_value.read.return_value = json.dumps({
376
+ "data": [
377
+ {"index": i, "embedding": [float(text[1:]), 1.0]}
378
+ for i, text in enumerate(inputs)
379
+ ]
380
+ }).encode()
381
+ return response
382
+
383
+ with mock.patch("urllib.request.urlopen", side_effect=response_for) as mocked:
384
+ result = provider.embed_documents([f"t{i}" for i in range(257)])
385
+ self.assertEqual(mocked.call_count, 3)
386
+ self.assertEqual(
387
+ [len(json.loads(call.args[0].data)["input"]) for call in mocked.call_args_list],
388
+ [128, 128, 1],
389
+ )
390
+ self.assertEqual([row[0] for row in result], list(map(float, range(257))))
391
+
392
+ def test_voyage_configurable_batch_size_and_partial_failure(self):
393
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
394
+ provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=1, batch_size=2)
395
+ good = mock.MagicMock()
396
+ good.__enter__.return_value.read.return_value = json.dumps({
397
+ "data": [{"index": 0, "embedding": [1.0]}, {"index": 1, "embedding": [2.0]}]
398
+ }).encode()
399
+ with mock.patch(
400
+ "urllib.request.urlopen",
401
+ side_effect=[good, urllib.error.URLError("offline")],
402
+ ):
403
+ with self.assertRaisesRegex(RuntimeError, "batch 2"):
404
+ provider.embed_documents(["a", "b", "c"])
405
+
406
+ def test_voyage_response_validation(self):
407
+ from tools.codegraph_voyage.providers import VoyageEmbeddingProvider
408
+ provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
409
+ invalid = [
410
+ {"data": [{"index": 0, "embedding": [0.1, 0.2]}]},
411
+ {"data": [{"index": 0, "embedding": [0.1, 0.2]}, {"index": 0, "embedding": [0.3, 0.4]}]},
412
+ {"data": [{"index": 0, "embedding": [0.1]}, {"index": 1, "embedding": [0.3, 0.4]}]},
413
+ {"unexpected": []},
414
+ ]
415
+ for payload in invalid:
416
+ with self.subTest(payload=payload), mock.patch("urllib.request.urlopen") as mocked:
417
+ mocked.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode()
418
+ with self.assertRaisesRegex(RuntimeError, "response validation"):
419
+ provider.embed_documents(["a", "b"])
420
+
421
+ def test_create_provider(self):
422
+ from tools.codegraph_voyage.providers import create_provider, FakeEmbeddingProvider, VoyageEmbeddingProvider
423
+ p1 = create_provider("fake")
424
+ self.assertIsInstance(p1, FakeEmbeddingProvider)
425
+ with self.assertRaises(ValueError):
426
+ create_provider("unknown")
427
+
428
+
429
+ # =========================================================================
430
+ # Sidecar DB
431
+ # =========================================================================
432
+
433
+ class TestSidecarDB(unittest.TestCase):
434
+ """SidecarDB create, store, retrieve, stale removal, incremental indexing."""
435
+
436
+ def setUp(self):
437
+ self.tmpdir = Path(tempfile.mkdtemp())
438
+ self.db_path = self.tmpdir / "sidecar.db"
439
+
440
+ def tearDown(self):
441
+ import shutil
442
+ shutil.rmtree(str(self.tmpdir), ignore_errors=True)
443
+
444
+ def _make_provider(self, name="fake-embedding-v1", dims=512):
445
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider
446
+ return FakeEmbeddingProvider(dimensions=dims, model=name)
447
+
448
+ def test_open_and_close(self):
449
+ from tools.codegraph_voyage.sidecar import SidecarDB
450
+ db = SidecarDB(self.db_path)
451
+ db.open()
452
+ self.assertIsNotNone(db.conn)
453
+ status = db.get_status()
454
+ self.assertTrue(status["connected"])
455
+ self.assertEqual(status["total_embeddings"], 0)
456
+ pk_columns = [
457
+ row[1]
458
+ for row in sorted(
459
+ (row for row in db.conn.execute("PRAGMA table_info(embeddings)") if row[5]),
460
+ key=lambda row: row[5],
461
+ )
462
+ ]
463
+ self.assertEqual(
464
+ pk_columns,
465
+ ["node_id", "source_content_hash", "model", "dimensions", "dtype"],
466
+ )
467
+ db.close()
468
+ with self.assertRaises(Exception):
469
+ db.conn
470
+
471
+ def test_store_and_retrieve(self):
472
+ from tools.codegraph_voyage.sidecar import SidecarDB
473
+ provider = self._make_provider(dims=3)
474
+ db = SidecarDB(self.db_path)
475
+ db.open()
476
+ try:
477
+ records = [{
478
+ "node_id": "n1",
479
+ "content_hash": "abc",
480
+ "embedding": [0.1, 0.2, 0.3],
481
+ "node_kind": "function",
482
+ "name": "hello",
483
+ "qualified_name": "mod.hello",
484
+ "file_path": "src/main.py",
485
+ "language": "python",
486
+ "start_line": 1,
487
+ "end_line": 5,
488
+ "document_text": "def hello(): pass",
489
+ }]
490
+ stored = db.store_embeddings(records, provider)
491
+ self.assertEqual(stored, 1)
492
+
493
+ # Retrieve
494
+ emb = db.get_embedding("n1", provider)
495
+ self.assertIsNotNone(emb)
496
+ self.assertEqual(len(emb), 3)
497
+ self.assertAlmostEqual(emb[0], 0.1)
498
+
499
+ # All embeddings
500
+ all_emb = db.get_all_embeddings(provider)
501
+ self.assertEqual(len(all_emb), 1)
502
+ self.assertEqual(all_emb[0]["node_id"], "n1")
503
+ finally:
504
+ db.close()
505
+
506
+ def test_replace_existing(self):
507
+ from tools.codegraph_voyage.sidecar import SidecarDB
508
+ provider = self._make_provider(dims=3)
509
+ db = SidecarDB(self.db_path)
510
+ db.open()
511
+ try:
512
+ db.store_embeddings([{
513
+ "node_id": "n1",
514
+ "content_hash": "abc",
515
+ "embedding": [0.1, 0.2, 0.3],
516
+ "node_kind": "function",
517
+ "name": "hello",
518
+ "qualified_name": "mod.hello",
519
+ "file_path": "src/main.py",
520
+ "language": "python",
521
+ "start_line": 1,
522
+ "end_line": 5,
523
+ "document_text": "def hello(): pass",
524
+ }], provider)
525
+ db.store_embeddings([{
526
+ "node_id": "n1",
527
+ "content_hash": "def",
528
+ "embedding": [0.9, 0.8, 0.7],
529
+ "node_kind": "function",
530
+ "name": "hello",
531
+ "qualified_name": "mod.hello",
532
+ "file_path": "src/main.py",
533
+ "language": "python",
534
+ "start_line": 1,
535
+ "end_line": 5,
536
+ "document_text": "def hello(): pass",
537
+ }], provider)
538
+ emb = db.get_embedding("n1", provider)
539
+ self.assertAlmostEqual(emb[0], 0.9)
540
+ self.assertEqual(db.get_status()["total_embeddings"], 1)
541
+ finally:
542
+ db.close()
543
+
544
+ def test_find_changed_nodes(self):
545
+ from tools.codegraph_voyage.sidecar import SidecarDB
546
+ provider = self._make_provider(dims=2)
547
+ db = SidecarDB(self.db_path)
548
+ db.open()
549
+ try:
550
+ # Store one
551
+ db.store_embeddings([{
552
+ "node_id": "n1",
553
+ "content_hash": "abc",
554
+ "embedding": [0.1, 0.2],
555
+ "node_kind": "function",
556
+ "name": "hello",
557
+ "qualified_name": "",
558
+ "file_path": "src/main.py",
559
+ "language": "python",
560
+ "start_line": 1,
561
+ "end_line": 5,
562
+ "document_text": "def hello(): pass",
563
+ }], provider)
564
+
565
+ # Same hash → unchanged
566
+ docs = [{"node_id": "n1", "content_hash": "abc"}]
567
+ changed = db.find_changed_nodes(docs, provider)
568
+ self.assertEqual(len(changed), 0)
569
+
570
+ # Different hash → changed
571
+ docs2 = [{"node_id": "n1", "content_hash": "xyz"}]
572
+ changed2 = db.find_changed_nodes(docs2, provider)
573
+ self.assertEqual(len(changed2), 1)
574
+
575
+ # New node → changed
576
+ docs3 = [{"node_id": "n2", "content_hash": "new"}]
577
+ changed3 = db.find_changed_nodes(docs3, provider)
578
+ self.assertEqual(len(changed3), 1)
579
+ finally:
580
+ db.close()
581
+
582
+ def test_remove_stale_records(self):
583
+ from tools.codegraph_voyage.sidecar import SidecarDB
584
+ provider = self._make_provider(dims=1)
585
+ db = SidecarDB(self.db_path)
586
+ db.open()
587
+ try:
588
+ db.store_embeddings([
589
+ {
590
+ "node_id": "n1",
591
+ "content_hash": "a",
592
+ "embedding": [0.1],
593
+ "node_kind": "f",
594
+ "name": "a",
595
+ "qualified_name": "a",
596
+ "file_path": "a.py",
597
+ "language": "py",
598
+ "start_line": 1,
599
+ "end_line": 2,
600
+ "document_text": "a",
601
+ },
602
+ {
603
+ "node_id": "n2",
604
+ "content_hash": "b",
605
+ "embedding": [0.2],
606
+ "node_kind": "f",
607
+ "name": "b",
608
+ "qualified_name": "b",
609
+ "file_path": "b.py",
610
+ "language": "py",
611
+ "start_line": 1,
612
+ "end_line": 2,
613
+ "document_text": "b",
614
+ },
615
+ ], provider)
616
+
617
+ # Remove n2 (stale)
618
+ removed = db.remove_stale_records({"n1"}, provider)
619
+ self.assertEqual(removed, 1)
620
+
621
+ # n1 should still exist
622
+ self.assertIsNotNone(db.get_embedding("n1", provider))
623
+ self.assertIsNone(db.get_embedding("n2", provider))
624
+ finally:
625
+ db.close()
626
+
627
+ def test_empty_current_set_removes_all_for_provider(self):
628
+ from tools.codegraph_voyage.sidecar import SidecarDB
629
+ provider = self._make_provider(dims=1)
630
+ db = SidecarDB(self.db_path)
631
+ db.open()
632
+ try:
633
+ db.store_embeddings([{
634
+ "node_id": "stale", "content_hash": "x", "embedding": [0.1],
635
+ "node_kind": "f", "name": "stale", "qualified_name": "stale",
636
+ "file_path": "stale.py", "language": "python",
637
+ "start_line": 1, "end_line": 1, "document_text": "stale",
638
+ }], provider)
639
+ self.assertEqual(db.remove_stale_records(set(), provider), 1)
640
+ self.assertIsNone(db.get_embedding("stale", provider))
641
+ finally:
642
+ db.close()
643
+
644
+ def test_dimension_mismatch_is_atomic(self):
645
+ from tools.codegraph_voyage.sidecar import SidecarDB, SidecarError
646
+ provider = self._make_provider(dims=2)
647
+ db = SidecarDB(self.db_path)
648
+ db.open()
649
+ try:
650
+ records = [
651
+ {"node_id": "ok", "content_hash": "a", "embedding": [0.1, 0.2]},
652
+ {"node_id": "bad", "content_hash": "b", "embedding": [0.3]},
653
+ ]
654
+ with self.assertRaises(SidecarError):
655
+ db.store_embeddings(records, provider)
656
+ self.assertEqual(db.get_status()["total_embeddings"], 0)
657
+ finally:
658
+ db.close()
659
+
660
+ def test_model_incompatibility(self):
661
+ """Different model/dimensions are segregated."""
662
+ from tools.codegraph_voyage.sidecar import SidecarDB
663
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider
664
+ provider_a = FakeEmbeddingProvider(dimensions=4, model="fake-model-a")
665
+ provider_b = FakeEmbeddingProvider(dimensions=4, model="fake-model-b")
666
+ db = SidecarDB(self.db_path)
667
+ db.open()
668
+ try:
669
+ db.store_embeddings([{
670
+ "node_id": "n1",
671
+ "content_hash": "a",
672
+ "embedding": [0.1, 0.2, 0.3, 0.4],
673
+ "node_kind": "f",
674
+ "name": "a",
675
+ "qualified_name": "a",
676
+ "file_path": "a.py",
677
+ "language": "py",
678
+ "start_line": 1,
679
+ "end_line": 2,
680
+ "document_text": "a",
681
+ }], provider_a)
682
+
683
+ # Provider B should not see n1 (different model, same dimensions)
684
+ self.assertIsNone(db.get_embedding("n1", provider_b))
685
+
686
+ # Provider A should see it
687
+ self.assertIsNotNone(db.get_embedding("n1", provider_a))
688
+ finally:
689
+ db.close()
690
+
691
+ def test_clear(self):
692
+ from tools.codegraph_voyage.sidecar import SidecarDB
693
+ provider = self._make_provider(dims=1)
694
+ db = SidecarDB(self.db_path)
695
+ db.open()
696
+ try:
697
+ db.store_embeddings([{
698
+ "node_id": "n1",
699
+ "content_hash": "a",
700
+ "embedding": [0.1],
701
+ "node_kind": "f",
702
+ "name": "a",
703
+ "qualified_name": "",
704
+ "file_path": "a.py",
705
+ "language": "py",
706
+ "start_line": 1,
707
+ "end_line": 2,
708
+ "document_text": "a",
709
+ }], provider)
710
+ cleared = db.clear(provider)
711
+ self.assertEqual(cleared, 1)
712
+ self.assertEqual(db.get_status()["total_embeddings"], 0)
713
+ finally:
714
+ db.close()
715
+
716
+
717
+ # =========================================================================
718
+ # Ranking
719
+ # =========================================================================
720
+
721
+ class TestRanking(unittest.TestCase):
722
+ """Pinned candidates, lexical ranking, vector ranking, RRF fusion, hybrid search."""
723
+
724
+ def _make_doc(self, node_id: str, name: str = "", qname: str = "",
725
+ fpath: str = "", kind: str = "function", doc_text: str = "",
726
+ embedding: list[float] | None = None) -> dict[str, Any]:
727
+ return {
728
+ "node_id": node_id,
729
+ "name": name,
730
+ "qualified_name": qname,
731
+ "file_path": fpath,
732
+ "node_kind": kind,
733
+ "language": "python",
734
+ "start_line": 1,
735
+ "end_line": 5,
736
+ "document_text": doc_text or f"def {name}: pass",
737
+ "embedding": embedding or [0.0] * 4,
738
+ }
739
+
740
+ def test_pinned_exact_name(self):
741
+ from tools.codegraph_voyage.ranking import find_pinned_candidates
742
+ candidates = [
743
+ self._make_doc("n1", name="AuthService", qname="app.AuthService"),
744
+ self._make_doc("n2", name="UserModel", qname="app.UserModel"),
745
+ ]
746
+ pinned = find_pinned_candidates("AuthService", candidates)
747
+ self.assertEqual(len(pinned), 1)
748
+ self.assertEqual(pinned[0].node_id, "n1")
749
+ self.assertTrue(pinned[0].is_pinned)
750
+ self.assertIn("exact_name", pinned[0].provenance)
751
+
752
+ # The same node may arrive from both vector and lexical candidate lists.
753
+ deduped = find_pinned_candidates("AuthService", candidates + candidates)
754
+ self.assertEqual([p.node_id for p in deduped], ["n1"])
755
+
756
+ def test_pinned_path_match(self):
757
+ from tools.codegraph_voyage.ranking import find_pinned_candidates
758
+ candidates = [
759
+ self._make_doc("n1", name="f1", fpath="src/auth/login.py"),
760
+ self._make_doc("n2", name="f2", fpath="src/utils/helper.py"),
761
+ ]
762
+ self.assertEqual(find_pinned_candidates("auth", candidates), [])
763
+ pinned = find_pinned_candidates("login.py", candidates)
764
+ self.assertEqual([p.node_id for p in pinned], ["n1"])
765
+ self.assertIn("exact_basename", pinned[0].provenance)
766
+ exact_path = find_pinned_candidates("SRC/AUTH/LOGIN.PY", candidates)
767
+ self.assertEqual([p.node_id for p in exact_path], ["n1"])
768
+ self.assertIn("exact_path", exact_path[0].provenance)
769
+
770
+ def test_pinned_partial_qname(self):
771
+ from tools.codegraph_voyage.ranking import find_pinned_candidates
772
+ candidates = [
773
+ self._make_doc("n1", qname="app.services.auth.AuthService"),
774
+ self._make_doc("n2", qname="app.models.User"),
775
+ ]
776
+ self.assertEqual(find_pinned_candidates("auth service", candidates), [])
777
+
778
+ def test_pinning_rejects_empty_and_substring_queries(self):
779
+ from tools.codegraph_voyage.ranking import find_pinned_candidates
780
+ candidates = [self._make_doc("n1", name="Alpha", fpath="src/data.py")]
781
+ self.assertEqual(find_pinned_candidates("", candidates), [])
782
+ self.assertEqual(find_pinned_candidates(" ", candidates), [])
783
+ self.assertEqual(find_pinned_candidates("a", candidates), [])
784
+
785
+ def test_cosine_similarity(self):
786
+ from tools.codegraph_voyage.ranking import cosine_similarity
787
+ self.assertAlmostEqual(cosine_similarity([1, 0], [1, 0]), 1.0)
788
+ self.assertAlmostEqual(cosine_similarity([1, 0], [0, 1]), 0.0)
789
+ self.assertAlmostEqual(cosine_similarity([1, 1], [1, 1]), 1.0)
790
+ # Zero vectors
791
+ self.assertEqual(cosine_similarity([0, 0], [0, 0]), 0.0)
792
+
793
+ def test_rank_by_vector_similarity(self):
794
+ from tools.codegraph_voyage.ranking import rank_by_vector_similarity
795
+ qv = [1.0, 0.0, 0.0, 0.0]
796
+ candidates = [
797
+ self._make_doc("n1", embedding=[0.9, 0.1, 0.0, 0.0]),
798
+ self._make_doc("n2", embedding=[0.0, 0.9, 0.0, 0.0]),
799
+ ]
800
+ results = rank_by_vector_similarity(qv, candidates, top_k=5)
801
+ self.assertEqual(len(results), 2)
802
+ # n1 is more similar (closer to [1,0,0,0])
803
+ self.assertEqual(results[0].node_id, "n1")
804
+ self.assertGreater(results[0].vector_score, results[1].vector_score)
805
+
806
+ def test_rank_by_lexical_similarity(self):
807
+ from tools.codegraph_voyage.ranking import rank_by_lexical_similarity
808
+ candidates = [
809
+ self._make_doc("n1", name="a", doc_text="auth service login handler"),
810
+ self._make_doc("n2", name="b", doc_text="user model data access"),
811
+ ]
812
+ results = rank_by_lexical_similarity("auth login", candidates, top_k=5)
813
+ self.assertEqual(len(results), 2)
814
+ self.assertEqual(results[0].node_id, "n1")
815
+
816
+ def test_reciprocal_rank_fusion(self):
817
+ from tools.codegraph_voyage.ranking import (
818
+ reciprocal_rank_fusion, RankingResult,
819
+ )
820
+ list_a = [
821
+ RankingResult(node_id="n1", score=1.0, provenance="vector"),
822
+ RankingResult(node_id="n2", score=0.8, provenance="vector"),
823
+ ]
824
+ list_b = [
825
+ RankingResult(node_id="n2", score=1.0, provenance="lexical"),
826
+ RankingResult(node_id="n3", score=0.9, provenance="lexical"),
827
+ ]
828
+ fused = reciprocal_rank_fusion([list_a, list_b], weights=[0.5, 0.5], k=60)
829
+ self.assertEqual(len(fused), 3)
830
+ # n2 appears in both lists → highest fused score
831
+ self.assertEqual(fused[0].node_id, "n2")
832
+ self.assertIn("vector", fused[0].provenance)
833
+ self.assertIn("lexical", fused[0].provenance)
834
+
835
+ def test_hybrid_search_pinned(self):
836
+ from tools.codegraph_voyage.ranking import hybrid_search
837
+ qv = [1.0, 0.0, 0.0, 0.0]
838
+ vector_candidates = [
839
+ self._make_doc("n1", name="AuthService", embedding=[0.9, 0.0, 0.0, 0.0]),
840
+ self._make_doc("n2", name="Helper", embedding=[0.0, 0.9, 0.0, 0.0]),
841
+ ]
842
+ lexical_candidates = list(vector_candidates)
843
+ results = hybrid_search("AuthService", qv, vector_candidates, lexical_candidates, top_k=5)
844
+ self.assertEqual(len(results), 2)
845
+ # AuthService should be pinned to top
846
+ self.assertEqual(results[0].node_id, "n1")
847
+ self.assertTrue(results[0].is_pinned)
848
+
849
+ def test_merge_pinned_into_results(self):
850
+ from tools.codegraph_voyage.ranking import merge_pinned_into_results, RankingResult
851
+ pinned = [
852
+ RankingResult(node_id="n1", score=10.0, is_pinned=True, exact_score=10.0),
853
+ RankingResult(node_id="n2", score=5.0, is_pinned=True, exact_score=5.0),
854
+ ]
855
+ fused = [
856
+ RankingResult(node_id="n3", score=0.5),
857
+ RankingResult(node_id="n1", score=0.3, lexical_score=0.7,
858
+ vector_score=0.8, provenance="lexical+vector"),
859
+ ]
860
+ merged = merge_pinned_into_results(pinned, fused)
861
+ self.assertEqual(len(merged), 3)
862
+ self.assertEqual(merged[0].node_id, "n1")
863
+ self.assertEqual(merged[1].node_id, "n2")
864
+ self.assertEqual(merged[2].node_id, "n3")
865
+ self.assertEqual(merged[0].lexical_score, 0.7)
866
+ self.assertEqual(merged[0].vector_score, 0.8)
867
+ self.assertIn("lexical", merged[0].provenance)
868
+ self.assertIn("vector", merged[0].provenance)
869
+
870
+ def test_ranking_result_to_dict(self):
871
+ from tools.codegraph_voyage.ranking import RankingResult
872
+ r = RankingResult(
873
+ node_id="n1", score=0.5, is_pinned=True,
874
+ lexical_score=0.3, vector_score=0.2,
875
+ name="hello", provenance="lexical+vector",
876
+ )
877
+ d = r.to_dict()
878
+ self.assertEqual(d["node_id"], "n1")
879
+ self.assertEqual(d["score"], 0.5)
880
+ self.assertTrue(d["is_pinned"])
881
+
882
+
883
+ # =========================================================================
884
+ # Explore integration
885
+ # =========================================================================
886
+
887
+ class TestExplore(unittest.TestCase):
888
+ """build_explore_query and codegraph_explore (dry-run)."""
889
+
890
+ def test_build_explore_query(self):
891
+ from tools.codegraph_voyage.ranking import RankingResult
892
+ from tools.codegraph_voyage.explore import build_explore_query
893
+ candidates = [
894
+ RankingResult(node_id="n1", name="hello", qualified_name="mod.hello", file_path="src/a.py"),
895
+ RankingResult(node_id="n2", name="world", qualified_name="mod.world", file_path="src/b.py"),
896
+ ]
897
+ q = build_explore_query(candidates, max_symbols=10)
898
+ self.assertIn("mod.hello", q)
899
+ self.assertIn("mod.world", q)
900
+
901
+ def test_codegraph_explore_dry_run(self):
902
+ from tools.codegraph_voyage.ranking import RankingResult
903
+ from tools.codegraph_voyage.explore import codegraph_explore
904
+ candidates = [
905
+ RankingResult(node_id="n1", name="hello", qualified_name="mod.hello"),
906
+ ]
907
+ result = codegraph_explore(candidates, project_path="/tmp", dry_run=True)
908
+ self.assertIn("command", result)
909
+ self.assertIn("codegraph explore", result["command"])
910
+
911
+ def test_codegraph_explore_fake_executable(self):
912
+ from tools.codegraph_voyage.ranking import RankingResult
913
+ from tools.codegraph_voyage.explore import codegraph_explore
914
+ with tempfile.TemporaryDirectory() as tmp:
915
+ fake = Path(tmp) / "fake-codegraph"
916
+ fake.write_text("#!/bin/sh\nprintf 'FAKE_EXPLORE:%s\\n' \"$*\"\n")
917
+ fake.chmod(0o755)
918
+ candidates = [RankingResult(
919
+ node_id="n1", name="AuthService", qualified_name="app.AuthService"
920
+ )]
921
+ result = codegraph_explore(
922
+ candidates, project_path=tmp, codegraph_bin=str(fake), timeout=5
923
+ )
924
+ self.assertEqual(result["returncode"], 0)
925
+ self.assertIn("FAKE_EXPLORE:explore", result["stdout"])
926
+ self.assertIn("app.AuthService", result["stdout"])
927
+
928
+ def test_codegraph_explore_no_candidates(self):
929
+ from tools.codegraph_voyage.explore import codegraph_explore
930
+ result = codegraph_explore([], project_path="/tmp")
931
+ self.assertEqual(result["returncode"], 1)
932
+ self.assertEqual(result["error"], "No candidates")
933
+
934
+
935
+ # =========================================================================
936
+ # CLI
937
+ # =========================================================================
938
+
939
+ class TestCLI(unittest.TestCase):
940
+ """CLI argument parsing and command dispatch."""
941
+
942
+ def test_missing_voyage_key_fails_actionably(self):
943
+ import argparse
944
+ from tools.codegraph_voyage.cli import _make_provider
945
+ args = argparse.Namespace(provider="voyage", model="voyage-code-4", dimensions=16)
946
+ with mock.patch.dict(os.environ, {}, clear=True):
947
+ with self.assertRaisesRegex(ValueError, "VOYAGE_API_KEY"):
948
+ _make_provider(args)
949
+
950
+ def test_search_rejects_incompatible_sidecar_model(self):
951
+ import argparse
952
+ from tools.codegraph_voyage.cli import cmd_search
953
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider
954
+ from tools.codegraph_voyage.sidecar import SidecarDB
955
+ with tempfile.TemporaryDirectory() as tmp:
956
+ root = Path(tmp)
957
+ graph_dir = root / ".codegraph"
958
+ graph_dir.mkdir()
959
+ (graph_dir / "codegraph.db").touch()
960
+ sidecar = SidecarDB(graph_dir / "codegraph-voyage.db")
961
+ sidecar.open()
962
+ sidecar.store_embeddings([{
963
+ "node_id": "n1", "content_hash": "h1", "embedding": [0.1, 0.2],
964
+ "node_kind": "f", "name": "n1", "qualified_name": "n1",
965
+ "file_path": "n1.py", "language": "python", "start_line": 1,
966
+ "end_line": 1, "document_text": "n1",
967
+ }], FakeEmbeddingProvider(dimensions=2, model="voyage-code-4"))
968
+ sidecar.close()
969
+ args = argparse.Namespace(
970
+ project=str(root), query="n1", provider="fake", model="voyage-code-4",
971
+ dimensions=2, no_source=False, max_source_lines=20, kind=None,
972
+ file_filter=None, top_k=5, lexical_weight=0.5, vector_weight=0.5,
973
+ rrf_k=60, json=False,
974
+ )
975
+ stderr = io.StringIO()
976
+ with mock.patch(
977
+ "tools.codegraph_voyage.cli.build_documents_from_db", return_value=[]
978
+ ), mock.patch("sys.stderr", stderr):
979
+ rc = cmd_search(args)
980
+ self.assertEqual(rc, 2)
981
+ self.assertIn("Sidecar contains voyage-code-4 dims=2", stderr.getvalue())
982
+ self.assertIn("clear and rebuild", stderr.getvalue())
983
+
984
+ def test_search_json_stdout_is_parseable_list(self):
985
+ import argparse
986
+ from tools.codegraph_voyage.cli import cmd_search
987
+ from tools.codegraph_voyage.sidecar import SidecarDB
988
+ with tempfile.TemporaryDirectory() as tmp:
989
+ root = Path(tmp)
990
+ graph_dir = root / ".codegraph"
991
+ graph_dir.mkdir()
992
+ (graph_dir / "codegraph.db").touch()
993
+ sidecar = SidecarDB(graph_dir / "codegraph-voyage.db")
994
+ sidecar.open()
995
+ sidecar.close()
996
+ args = argparse.Namespace(
997
+ project=str(root), query="n1", provider="fake", model="voyage-code-4",
998
+ dimensions=2, no_source=False, max_source_lines=20, kind=None,
999
+ file_filter=None, top_k=5, lexical_weight=0.5, vector_weight=0.5,
1000
+ rrf_k=60, json=True,
1001
+ )
1002
+ stdout = io.StringIO()
1003
+ with mock.patch(
1004
+ "tools.codegraph_voyage.cli.build_documents_from_db", return_value=[]
1005
+ ), mock.patch("sys.stdout", stdout):
1006
+ rc = cmd_search(args)
1007
+ self.assertEqual(rc, 0)
1008
+ self.assertIsInstance(json.loads(stdout.getvalue()), list)
1009
+
1010
+ def _assert_failed_index_preserves_sidecar(self, urlopen_side_effect=None, response=None):
1011
+ import argparse
1012
+ from tools.codegraph_voyage.cli import cmd_index
1013
+ from tools.codegraph_voyage.providers import FakeEmbeddingProvider, VoyageEmbeddingProvider
1014
+ from tools.codegraph_voyage.sidecar import SidecarDB
1015
+ with tempfile.TemporaryDirectory() as tmp:
1016
+ root = Path(tmp)
1017
+ graph_dir = root / ".codegraph"
1018
+ graph_dir.mkdir()
1019
+ (graph_dir / "codegraph.db").touch()
1020
+ db_path = graph_dir / "codegraph-voyage.db"
1021
+ sidecar = SidecarDB(db_path)
1022
+ sidecar.open()
1023
+ sidecar.store_embeddings([{
1024
+ "node_id": "sentinel", "content_hash": "original", "embedding": [0.5],
1025
+ "node_kind": "f", "name": "sentinel", "qualified_name": "sentinel",
1026
+ "file_path": "sentinel.py", "language": "python", "start_line": 1,
1027
+ "end_line": 1, "document_text": "sentinel",
1028
+ }], FakeEmbeddingProvider(dimensions=1, model="sentinel-model"))
1029
+ before = sidecar.conn.execute(
1030
+ "SELECT node_id, source_content_hash, model FROM embeddings ORDER BY node_id"
1031
+ ).fetchall()
1032
+ sidecar.close()
1033
+ doc = {
1034
+ "node_id": "new", "document": 'api_key = "do-not-send"',
1035
+ "node_kind": "f", "name": "new", "qualified_name": "new",
1036
+ "file_path": "src/new.py", "language": "python", "start_line": 1,
1037
+ "end_line": 1,
1038
+ }
1039
+ args = argparse.Namespace(
1040
+ project=str(root), provider="voyage", model="voyage-code-4", dimensions=2,
1041
+ no_source=False, max_source_lines=20, kind=None, file_filter=None,
1042
+ )
1043
+ provider = VoyageEmbeddingProvider(api_key="test-key", dimensions=2)
1044
+ stderr = io.StringIO()
1045
+ patcher = mock.patch("urllib.request.urlopen")
1046
+ mocked_urlopen = patcher.start()
1047
+ if urlopen_side_effect is not None:
1048
+ mocked_urlopen.side_effect = urlopen_side_effect
1049
+ else:
1050
+ mocked_urlopen.return_value.__enter__.return_value.read.return_value = response
1051
+ try:
1052
+ with mock.patch(
1053
+ "tools.codegraph_voyage.cli._make_provider_or_report", return_value=provider
1054
+ ), mock.patch(
1055
+ "tools.codegraph_voyage.cli.build_documents_from_db", return_value=[doc]
1056
+ ), mock.patch("sys.stderr", stderr):
1057
+ rc = cmd_index(args)
1058
+ finally:
1059
+ patcher.stop()
1060
+ sidecar.open()
1061
+ after = sidecar.conn.execute(
1062
+ "SELECT node_id, source_content_hash, model FROM embeddings ORDER BY node_id"
1063
+ ).fetchall()
1064
+ sidecar.close()
1065
+ self.assertEqual(rc, 2)
1066
+ self.assertEqual(after, before)
1067
+ self.assertIn("sidecar left unchanged", stderr.getvalue())
1068
+ return stderr.getvalue()
1069
+
1070
+ def test_index_http_failure_is_atomic(self):
1071
+ error = urllib.error.HTTPError(
1072
+ url="https://api.voyageai.com/v1/embeddings", code=503,
1073
+ msg="Unavailable", hdrs=email.message.Message(), fp=None,
1074
+ )
1075
+ stderr = self._assert_failed_index_preserves_sidecar(urlopen_side_effect=error)
1076
+ self.assertIn("503", stderr)
1077
+
1078
+ def test_index_malformed_voyage_response_is_atomic(self):
1079
+ stderr = self._assert_failed_index_preserves_sidecar(
1080
+ response=json.dumps({"data": []}).encode()
1081
+ )
1082
+ self.assertIn("response validation failed", stderr)
1083
+
1084
+ def test_parser_accepts_commands(self):
1085
+ from tools.codegraph_voyage.cli import _build_parser
1086
+ ap = _build_parser()
1087
+ # Check commands are registered (--help exits, so catch SystemExit)
1088
+ for cmd in ["index", "search", "status", "explore"]:
1089
+ sub = ap._subparsers._group_actions[0]
1090
+ self.assertIn(cmd, sub.choices)
1091
+
1092
+ def test_help_prints(self):
1093
+ from tools.codegraph_voyage.cli import main
1094
+ # --help on the main parser exits with code 0; catch SystemExit
1095
+ with self.assertRaises(SystemExit) as ctx:
1096
+ main(["--help"])
1097
+ self.assertEqual(ctx.exception.code, 0)
1098
+
1099
+
1100
+ # =========================================================================
1101
+ # Sanitize test
1102
+ # =========================================================================
1103
+
1104
+ class TestSanitizeModule(unittest.TestCase):
1105
+ """Module-level exports."""
1106
+
1107
+ def test_excluded_path_patterns(self):
1108
+ from tools.codegraph_voyage.sanitize import EXCLUDED_PATH_PATTERNS
1109
+ self.assertIsInstance(EXCLUDED_PATH_PATTERNS, list)
1110
+ self.assertTrue(len(EXCLUDED_PATH_PATTERNS) > 0)
1111
+
1112
+
1113
+ if __name__ == "__main__":
1114
+ unittest.main()