gabriel / muse public
test_cmd_index.py python
685 lines 28.5 KB
Raw
1 """Tests for ``muse code index`` (status / rebuild / purge).
2
3 Coverage layers
4 ---------------
5 Unit
6 _build_symbol_history — empty repo, single commit with ops, manifest
7 cache (blob fetched once per obj_id), missing
8 manifest logged+skipped, no-op commits skipped,
9 SymbolCache consulted before read_object,
10 SymbolCache populated on miss.
11 _build_hash_occurrence — HEAD present, no HEAD (graceful empty), missing
12 manifest logged+empty, imports excluded, trivial
13 (size-1) entries excluded.
14 index_info — present, absent, corrupt states; entries is int.
15 purge_index — deletes existing file, returns False when absent,
16 raises ValueError for unknown name.
17
18 Integration (live repo, CliRunner)
19 status: exit-0, JSON keys + types, absent text, present text.
20 rebuild: exit-0, JSON schema, all counts, text output.
21 rebuild --dry-run: no files written, JSON dry_run=true, counts correct.
22 rebuild --index symbol_history: only that index rebuilt.
23 rebuild --index hash_occurrence: only that index rebuilt.
24 purge: exit-0, JSON schema, present deleted, absent skipped.
25 purge --index <name>: only named index deleted.
26 Invalid --index rejected by argparse (exit non-zero).
27 Missing repo exits non-zero.
28
29 E2E (real symbol changes across commits)
30 After rebuild, status shows both indexes as present with non-zero entries.
31 symbol_history entries reflect commit history (insert recorded).
32 hash_occurrence clusters > 0 when duplicate bodies exist.
33 Rebuild is idempotent: two consecutive rebuilds yield identical JSON.
34 Dry-run counts match a subsequent real rebuild.
35 Purge then status shows absent; rebuild restores present.
36 Purge --index only removes targeted index.
37
38 Stress
39 50-commit repo: rebuild completes, all symbol_history addresses > 0.
40 Manifest cache: blob fetched at most once per unique obj_id during rebuild.
41 Large flat file (200 functions): hash_occurrence correct after rebuild.
42 """
43
44 from __future__ import annotations
45
46 type _CountMap = dict[str, int]
47
48 import json
49 import pathlib
50 import textwrap
51 import time
52 from typing import TypedDict
53 from unittest import mock
54
55 import pytest
56 from tests.cli_test_helper import CliRunner
57
58 from muse.cli.commands.index_rebuild import _build_hash_occurrence, _build_symbol_history
59 from muse.core.indices import (
60 KNOWN_INDEX_NAMES,
61 IndexInfoEntry,
62 SymbolHistoryEntry,
63 index_info,
64 purge_index,
65 )
66 from muse.core.symbol_cache import SymbolCache
67
68 # ---------------------------------------------------------------------------
69 # Runner
70 # ---------------------------------------------------------------------------
71
72 runner = CliRunner()
73 cli = None # CliRunner always targets muse.cli.app.main
74
75
76 # ---------------------------------------------------------------------------
77 # TypedDicts for JSON schema validation
78 # ---------------------------------------------------------------------------
79
80
81 class _StatusEntry(TypedDict):
82 name: str
83 status: str
84 entries: int
85 updated_at: str | None
86
87
88 class _RebuildPayload(TypedDict, total=False):
89 schema_version: str
90 dry_run: bool
91 rebuilt: list[str]
92 symbol_history_addresses: int
93 symbol_history_events: int
94 hash_occurrence_clusters: int
95 hash_occurrence_addresses: int
96
97
98 class _PurgePayload(TypedDict):
99 schema_version: str
100 purged: list[str]
101 skipped: list[str]
102
103
104 # ---------------------------------------------------------------------------
105 # Helpers
106 # ---------------------------------------------------------------------------
107
108
109 def _index_path(root: pathlib.Path, name: str) -> pathlib.Path:
110 return root / ".muse" / "indices" / f"{name}.msgpack"
111
112
113 def _index_exists(root: pathlib.Path, name: str) -> bool:
114 return _index_path(root, name).exists()
115
116
117 def _invoke_rebuild_json(extra: list[str] | None = None) -> _RebuildPayload:
118 args = ["code", "index", "rebuild", "--json"] + (extra or [])
119 result = runner.invoke(cli, args)
120 assert result.exit_code == 0, result.output
121 out: _RebuildPayload = json.loads(result.output)
122 return out
123
124
125 def _invoke_status_json() -> list[_StatusEntry]:
126 result = runner.invoke(cli, ["code", "index", "status", "--json"])
127 assert result.exit_code == 0, result.output
128 out: list[_StatusEntry] = json.loads(result.output)
129 return out
130
131
132 # ---------------------------------------------------------------------------
133 # Fixtures
134 # ---------------------------------------------------------------------------
135
136
137 @pytest.fixture
138 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
139 monkeypatch.chdir(tmp_path)
140 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
141 result = runner.invoke(cli, ["init", "--domain", "code"])
142 assert result.exit_code == 0, result.output
143 return tmp_path
144
145
146 @pytest.fixture
147 def two_commit_repo(repo: pathlib.Path) -> pathlib.Path:
148 """Repo with two commits: v1 has one function, v2 replaces it."""
149 (repo / "billing.py").write_text(textwrap.dedent("""\
150 def compute(items):
151 return sum(items)
152 """))
153 r1 = runner.invoke(cli, ["commit", "-m", "v1"])
154 assert r1.exit_code == 0, r1.output
155
156 (repo / "billing.py").write_text(textwrap.dedent("""\
157 def compute(items):
158 return sum(items) * 2
159 """))
160 r2 = runner.invoke(cli, ["commit", "-m", "v2"])
161 assert r2.exit_code == 0, r2.output
162 return repo
163
164
165 @pytest.fixture
166 def clone_repo(repo: pathlib.Path) -> pathlib.Path:
167 """Repo with two files containing identical body → one hash_occurrence cluster."""
168 body = "def helper():\n return True\n"
169 (repo / "a.py").write_text(body)
170 (repo / "b.py").write_text(body + "\ndef other():\n pass\n")
171 runner.invoke(cli, ["commit", "-m", "clones"])
172 return repo
173
174
175 # ---------------------------------------------------------------------------
176 # Unit — _build_symbol_history
177 # ---------------------------------------------------------------------------
178
179
180 class TestBuildSymbolHistory:
181 def test_empty_repo_returns_empty(self, repo: pathlib.Path) -> None:
182 idx = _build_symbol_history(repo)
183 assert isinstance(idx, dict)
184 assert len(idx) == 0
185
186 def test_after_commit_has_entries(self, two_commit_repo: pathlib.Path) -> None:
187 idx = _build_symbol_history(two_commit_repo)
188 assert len(idx) > 0
189
190 def test_address_contains_double_colon(self, two_commit_repo: pathlib.Path) -> None:
191 idx = _build_symbol_history(two_commit_repo)
192 assert all("::" in addr for addr in idx)
193
194 def test_entries_are_symbol_history_entry(self, two_commit_repo: pathlib.Path) -> None:
195 idx = _build_symbol_history(two_commit_repo)
196 for entries in idx.values():
197 for e in entries:
198 assert isinstance(e, SymbolHistoryEntry)
199
200 def test_missing_manifest_skipped_with_log(
201 self, two_commit_repo: pathlib.Path, caplog: pytest.LogCaptureFixture
202 ) -> None:
203 """Commits with missing snapshot manifests are logged and skipped."""
204 import logging
205 with caplog.at_level(logging.DEBUG, logger="muse.cli.commands.index_rebuild"):
206 with mock.patch(
207 "muse.cli.commands.index_rebuild.get_commit_snapshot_manifest",
208 return_value=None,
209 ):
210 idx = _build_symbol_history(two_commit_repo)
211 # All commits skipped → empty index
212 assert len(idx) == 0
213 assert any("Missing snapshot manifest" in r.message for r in caplog.records)
214
215 def test_manifest_cache_prevents_double_fetch(self, two_commit_repo: pathlib.Path) -> None:
216 """Each unique manifest (commit) is fetched at most once."""
217 original = __import__(
218 "muse.core.store", fromlist=["get_commit_snapshot_manifest"]
219 ).get_commit_snapshot_manifest
220
221 call_counts: _CountMap = {}
222
223 def counting_fetch(root: pathlib.Path, commit_id: str) -> Manifest | None:
224 call_counts[commit_id] = call_counts.get(commit_id, 0) + 1
225 result: Manifest | None = original(root, commit_id)
226 return result
227
228 with mock.patch(
229 "muse.cli.commands.index_rebuild.get_commit_snapshot_manifest",
230 side_effect=counting_fetch,
231 ):
232 _build_symbol_history(two_commit_repo)
233
234 for commit_id, count in call_counts.items():
235 assert count == 1, (
236 f"Manifest for commit {commit_id[:8]} fetched {count} times — expected 1"
237 )
238
239 def test_blob_cache_prevents_double_parse(self, two_commit_repo: pathlib.Path) -> None:
240 """Each unique blob (obj_id) is read at most once within a single run."""
241 original_read = __import__(
242 "muse.core.object_store", fromlist=["read_object"]
243 ).read_object
244
245 obj_fetch_count: _CountMap = {}
246
247 def counting_read(root: pathlib.Path, obj_id: str) -> bytes | None:
248 obj_fetch_count[obj_id] = obj_fetch_count.get(obj_id, 0) + 1
249 result: bytes | None = original_read(root, obj_id)
250 return result
251
252 with mock.patch(
253 "muse.cli.commands.index_rebuild.read_object",
254 side_effect=counting_read,
255 ):
256 _build_symbol_history(two_commit_repo)
257
258 duplicates = {oid: n for oid, n in obj_fetch_count.items() if n > 1}
259 assert not duplicates, (
260 f"Blobs fetched more than once: {duplicates} — blob_cache not working"
261 )
262
263 def test_symbol_cache_consulted_before_read_object(
264 self, two_commit_repo: pathlib.Path
265 ) -> None:
266 """When SymbolCache has a hit, read_object is never called for that obj_id."""
267 from muse.core.object_store import read_object as real_read
268 from muse.core.store import get_commit_snapshot_manifest
269
270 # Pre-populate a SymbolCache with every blob in every commit's manifest.
271 warm_cache = SymbolCache.empty()
272 from muse.core.store import get_all_commits
273 from muse.plugins.code.ast_parser import parse_symbols as real_parse
274 from muse.plugins.code._query import is_semantic
275
276 for commit in get_all_commits(two_commit_repo):
277 manifest = get_commit_snapshot_manifest(two_commit_repo, commit.commit_id) or {}
278 for fp, oid in manifest.items():
279 if is_semantic(fp) and warm_cache.get(oid) is None:
280 raw = real_read(two_commit_repo, oid)
281 if raw is not None:
282 warm_cache.put(oid, real_parse(raw, fp))
283
284 read_calls: list[str] = []
285
286 def spy_read(root: pathlib.Path, obj_id: str) -> bytes | None:
287 read_calls.append(obj_id)
288 result: bytes | None = real_read(root, obj_id)
289 return result
290
291 with mock.patch("muse.cli.commands.index_rebuild.read_object", side_effect=spy_read):
292 _build_symbol_history(two_commit_repo, symbol_cache=warm_cache)
293
294 assert read_calls == [], (
295 f"read_object called {len(read_calls)} times despite warm SymbolCache"
296 )
297
298 def test_symbol_cache_populated_on_miss(self, two_commit_repo: pathlib.Path) -> None:
299 """A cold SymbolCache is populated during _build_symbol_history."""
300 cold_cache = SymbolCache.empty()
301 assert cold_cache.size == 0
302 _build_symbol_history(two_commit_repo, symbol_cache=cold_cache)
303 # Cache should have been populated with at least one entry.
304 assert cold_cache.size > 0
305
306
307 # ---------------------------------------------------------------------------
308 # Unit — _build_hash_occurrence
309 # ---------------------------------------------------------------------------
310
311
312 class TestBuildHashOccurrence:
313 def test_no_head_returns_empty(self, repo: pathlib.Path) -> None:
314 """No commits → no HEAD ref → gracefully returns empty dict."""
315 idx = _build_hash_occurrence(repo)
316 assert idx == {}
317
318 def test_single_function_not_a_clone(self, repo: pathlib.Path) -> None:
319 (repo / "solo.py").write_text("def unique():\n return 42\n")
320 runner.invoke(cli, ["commit", "-m", "solo"])
321 idx = _build_hash_occurrence(repo)
322 # unique function appears only once → filtered out
323 assert all(len(addrs) > 1 for addrs in idx.values())
324
325 def test_identical_bodies_form_cluster(self, clone_repo: pathlib.Path) -> None:
326 idx = _build_hash_occurrence(clone_repo)
327 assert len(idx) > 0
328 # every cluster has ≥ 2 members
329 assert all(len(addrs) >= 2 for addrs in idx.values())
330
331 def test_imports_excluded(self, repo: pathlib.Path) -> None:
332 (repo / "mod.py").write_text("import os\nimport sys\ndef fn():\n return 1\n")
333 runner.invoke(cli, ["commit", "-m", "imports"])
334 idx = _build_hash_occurrence(repo)
335 for addrs in idx.values():
336 for addr in addrs:
337 assert "::import::" not in addr
338
339 def test_missing_manifest_returns_empty(self, two_commit_repo: pathlib.Path) -> None:
340 with mock.patch(
341 "muse.cli.commands.index_rebuild.get_commit_snapshot_manifest",
342 return_value=None,
343 ):
344 idx = _build_hash_occurrence(two_commit_repo)
345 assert idx == {}
346
347
348 # ---------------------------------------------------------------------------
349 # Unit — index_info and purge_index
350 # ---------------------------------------------------------------------------
351
352
353 class TestIndexInfo:
354 def test_absent_before_rebuild(self, repo: pathlib.Path) -> None:
355 infos = index_info(repo)
356 assert len(infos) == len(KNOWN_INDEX_NAMES)
357 for info in infos:
358 assert info["status"] == "absent"
359
360 def test_entries_is_int_not_str(self, repo: pathlib.Path) -> None:
361 infos = index_info(repo)
362 for info in infos:
363 assert isinstance(info["entries"], int), (
364 f"{info['name']}.entries should be int, got {type(info['entries'])}"
365 )
366
367 def test_present_after_rebuild(self, two_commit_repo: pathlib.Path) -> None:
368 runner.invoke(cli, ["code", "index", "rebuild"])
369 infos = index_info(two_commit_repo)
370 for info in infos:
371 assert info["status"] == "present"
372
373 def test_corrupt_index_reported(self, repo: pathlib.Path) -> None:
374 (repo / ".muse" / "indices").mkdir(parents=True, exist_ok=True)
375 _index_path(repo, "symbol_history").write_bytes(b"\xff\xfe corrupt garbage")
376 infos = index_info(repo)
377 sym = next(i for i in infos if i["name"] == "symbol_history")
378 assert sym["status"] == "corrupt"
379
380 def test_updated_at_is_none_when_absent(self, repo: pathlib.Path) -> None:
381 infos = index_info(repo)
382 for info in infos:
383 assert info["updated_at"] is None
384
385
386 class TestPurgeIndex:
387 def test_purge_existing_returns_true(self, two_commit_repo: pathlib.Path) -> None:
388 runner.invoke(cli, ["code", "index", "rebuild"])
389 assert _index_exists(two_commit_repo, "symbol_history")
390 result = purge_index(two_commit_repo, "symbol_history")
391 assert result is True
392 assert not _index_exists(two_commit_repo, "symbol_history")
393
394 def test_purge_absent_returns_false(self, repo: pathlib.Path) -> None:
395 result = purge_index(repo, "hash_occurrence")
396 assert result is False
397
398 def test_purge_unknown_name_raises(self, repo: pathlib.Path) -> None:
399 with pytest.raises(ValueError, match="Unknown index name"):
400 purge_index(repo, "nonexistent_index")
401
402
403 # ---------------------------------------------------------------------------
404 # Integration — CLI runner tests
405 # ---------------------------------------------------------------------------
406
407
408 class TestIndexStatusCLI:
409 def test_exit_zero(self, repo: pathlib.Path) -> None:
410 result = runner.invoke(cli, ["code", "index", "status"])
411 assert result.exit_code == 0
412
413 def test_json_is_list(self, repo: pathlib.Path) -> None:
414 result = runner.invoke(cli, ["code", "index", "status", "--json"])
415 assert result.exit_code == 0
416 data = json.loads(result.output)
417 assert isinstance(data, list)
418 assert len(data) == len(KNOWN_INDEX_NAMES)
419
420 def test_json_entry_keys(self, repo: pathlib.Path) -> None:
421 data = _invoke_status_json()
422 for entry in data:
423 for key in ("name", "status", "entries", "updated_at"):
424 assert key in entry, f"Missing key {key!r} in status entry"
425
426 def test_json_entries_is_int(self, repo: pathlib.Path) -> None:
427 data = _invoke_status_json()
428 for entry in data:
429 assert isinstance(entry["entries"], int)
430
431 def test_json_absent_status_before_rebuild(self, repo: pathlib.Path) -> None:
432 data = _invoke_status_json()
433 assert all(e["status"] == "absent" for e in data)
434
435 def test_text_contains_hint_to_rebuild(self, repo: pathlib.Path) -> None:
436 result = runner.invoke(cli, ["code", "index", "status"])
437 assert "muse code index rebuild" in result.output
438
439 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
440 monkeypatch.chdir(tmp_path)
441 result = runner.invoke(cli, ["code", "index", "status"])
442 assert result.exit_code != 0
443
444
445 class TestIndexRebuildCLI:
446 def test_exit_zero(self, two_commit_repo: pathlib.Path) -> None:
447 result = runner.invoke(cli, ["code", "index", "rebuild"])
448 assert result.exit_code == 0
449
450 def test_json_top_level_keys(self, two_commit_repo: pathlib.Path) -> None:
451 data = _invoke_rebuild_json()
452 for key in ("schema_version", "dry_run", "rebuilt",
453 "symbol_history_addresses", "symbol_history_events",
454 "hash_occurrence_clusters", "hash_occurrence_addresses"):
455 assert key in data, f"Missing key {key!r}"
456
457 def test_json_dry_run_false_by_default(self, two_commit_repo: pathlib.Path) -> None:
458 data = _invoke_rebuild_json()
459 assert data["dry_run"] is False
460
461 def test_json_rebuilt_contains_both(self, two_commit_repo: pathlib.Path) -> None:
462 data = _invoke_rebuild_json()
463 assert set(data["rebuilt"]) == set(KNOWN_INDEX_NAMES)
464
465 def test_rebuild_writes_files(self, two_commit_repo: pathlib.Path) -> None:
466 runner.invoke(cli, ["code", "index", "rebuild"])
467 assert _index_exists(two_commit_repo, "symbol_history")
468 assert _index_exists(two_commit_repo, "hash_occurrence")
469
470 def test_dry_run_no_files_written(self, two_commit_repo: pathlib.Path) -> None:
471 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
472 assert result.exit_code == 0
473 assert not _index_exists(two_commit_repo, "symbol_history")
474 assert not _index_exists(two_commit_repo, "hash_occurrence")
475
476 def test_dry_run_json_flag(self, two_commit_repo: pathlib.Path) -> None:
477 data = _invoke_rebuild_json(["--dry-run"])
478 assert data["dry_run"] is True
479
480 def test_dry_run_counts_match_real_rebuild(self, two_commit_repo: pathlib.Path) -> None:
481 dry = _invoke_rebuild_json(["--dry-run"])
482 real = _invoke_rebuild_json()
483 assert dry["symbol_history_addresses"] == real["symbol_history_addresses"]
484 assert dry["symbol_history_events"] == real["symbol_history_events"]
485 assert dry["hash_occurrence_clusters"] == real["hash_occurrence_clusters"]
486
487 def test_index_symbol_history_only(self, two_commit_repo: pathlib.Path) -> None:
488 data = _invoke_rebuild_json(["--index", "symbol_history"])
489 assert data["rebuilt"] == ["symbol_history"]
490 assert _index_exists(two_commit_repo, "symbol_history")
491 assert not _index_exists(two_commit_repo, "hash_occurrence")
492
493 def test_index_hash_occurrence_only(self, two_commit_repo: pathlib.Path) -> None:
494 data = _invoke_rebuild_json(["--index", "hash_occurrence"])
495 assert data["rebuilt"] == ["hash_occurrence"]
496 assert not _index_exists(two_commit_repo, "symbol_history")
497 assert _index_exists(two_commit_repo, "hash_occurrence")
498
499 def test_text_output_no_files_on_dry_run(self, two_commit_repo: pathlib.Path) -> None:
500 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
501 assert "dry run" in result.output.lower()
502
503 def test_text_output_rebuild_references_status(self, two_commit_repo: pathlib.Path) -> None:
504 result = runner.invoke(cli, ["code", "index", "rebuild"])
505 assert "muse code index status" in result.output
506
507 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
508 monkeypatch.chdir(tmp_path)
509 result = runner.invoke(cli, ["code", "index", "rebuild"])
510 assert result.exit_code != 0
511
512
513 class TestIndexPurgeCLI:
514 def test_exit_zero(self, two_commit_repo: pathlib.Path) -> None:
515 runner.invoke(cli, ["code", "index", "rebuild"])
516 result = runner.invoke(cli, ["code", "index", "purge"])
517 assert result.exit_code == 0
518
519 def test_json_schema(self, two_commit_repo: pathlib.Path) -> None:
520 runner.invoke(cli, ["code", "index", "rebuild"])
521 result = runner.invoke(cli, ["code", "index", "purge", "--json"])
522 assert result.exit_code == 0
523 data: _PurgePayload = json.loads(result.output)
524 assert "schema_version" in data
525 assert "purged" in data
526 assert "skipped" in data
527
528 def test_purge_all_deletes_files(self, two_commit_repo: pathlib.Path) -> None:
529 runner.invoke(cli, ["code", "index", "rebuild"])
530 runner.invoke(cli, ["code", "index", "purge"])
531 assert not _index_exists(two_commit_repo, "symbol_history")
532 assert not _index_exists(two_commit_repo, "hash_occurrence")
533
534 def test_purge_specific_index(self, two_commit_repo: pathlib.Path) -> None:
535 runner.invoke(cli, ["code", "index", "rebuild"])
536 result = runner.invoke(
537 cli, ["code", "index", "purge", "--index", "symbol_history", "--json"]
538 )
539 data: _PurgePayload = json.loads(result.output)
540 assert "symbol_history" in data["purged"]
541 assert not _index_exists(two_commit_repo, "symbol_history")
542 assert _index_exists(two_commit_repo, "hash_occurrence")
543
544 def test_purge_absent_shows_skipped(self, repo: pathlib.Path) -> None:
545 result = runner.invoke(cli, ["code", "index", "purge", "--json"])
546 data: _PurgePayload = json.loads(result.output)
547 assert data["purged"] == []
548 assert set(data["skipped"]) == set(KNOWN_INDEX_NAMES)
549
550 def test_purge_then_status_absent(self, two_commit_repo: pathlib.Path) -> None:
551 runner.invoke(cli, ["code", "index", "rebuild"])
552 runner.invoke(cli, ["code", "index", "purge"])
553 data = _invoke_status_json()
554 assert all(e["status"] == "absent" for e in data)
555
556 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
557 monkeypatch.chdir(tmp_path)
558 result = runner.invoke(cli, ["code", "index", "purge"])
559 assert result.exit_code != 0
560
561
562 # ---------------------------------------------------------------------------
563 # E2E — real commit history interactions
564 # ---------------------------------------------------------------------------
565
566
567 class TestIndexE2E:
568 def test_status_shows_present_after_rebuild(self, two_commit_repo: pathlib.Path) -> None:
569 runner.invoke(cli, ["code", "index", "rebuild"])
570 data = _invoke_status_json()
571 for entry in data:
572 assert entry["status"] == "present", f"{entry['name']} still absent"
573
574 def test_status_entries_nonzero_after_rebuild(self, two_commit_repo: pathlib.Path) -> None:
575 runner.invoke(cli, ["code", "index", "rebuild"])
576 data = _invoke_status_json()
577 sym = next(e for e in data if e["name"] == "symbol_history")
578 assert sym["entries"] > 0
579
580 def test_symbol_history_contains_billing_compute(self, two_commit_repo: pathlib.Path) -> None:
581 idx = _build_symbol_history(two_commit_repo)
582 assert any("billing.py::compute" in addr for addr in idx)
583
584 def test_hash_occurrence_cluster_for_clones(self, clone_repo: pathlib.Path) -> None:
585 idx = _build_hash_occurrence(clone_repo)
586 assert len(idx) > 0
587
588 def test_rebuild_is_idempotent(self, two_commit_repo: pathlib.Path) -> None:
589 d1 = _invoke_rebuild_json()
590 d2 = _invoke_rebuild_json()
591 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
592 assert d1["symbol_history_events"] == d2["symbol_history_events"]
593 assert d1["hash_occurrence_clusters"] == d2["hash_occurrence_clusters"]
594
595 def test_purge_then_rebuild_restores_present(self, two_commit_repo: pathlib.Path) -> None:
596 runner.invoke(cli, ["code", "index", "rebuild"])
597 runner.invoke(cli, ["code", "index", "purge"])
598 runner.invoke(cli, ["code", "index", "rebuild"])
599 data = _invoke_status_json()
600 for entry in data:
601 assert entry["status"] == "present"
602
603 def test_purge_index_only_removes_targeted(self, two_commit_repo: pathlib.Path) -> None:
604 runner.invoke(cli, ["code", "index", "rebuild"])
605 runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence"])
606 data = _invoke_status_json()
607 sym = next(e for e in data if e["name"] == "symbol_history")
608 ho = next(e for e in data if e["name"] == "hash_occurrence")
609 assert sym["status"] == "present"
610 assert ho["status"] == "absent"
611
612 def test_dry_run_counts_match_real_rebuild(self, two_commit_repo: pathlib.Path) -> None:
613 dry = _invoke_rebuild_json(["--dry-run"])
614 real = _invoke_rebuild_json()
615 for key in ("symbol_history_addresses", "symbol_history_events",
616 "hash_occurrence_clusters", "hash_occurrence_addresses"):
617 assert dry.get(key) == real.get(key), f"Mismatch on {key}"
618
619
620 # ---------------------------------------------------------------------------
621 # Stress
622 # ---------------------------------------------------------------------------
623
624
625 class TestIndexStress:
626 def test_50_commit_rebuild_completes(self, repo: pathlib.Path) -> None:
627 """50 commits, each changing one function — rebuild must complete."""
628 for i in range(50):
629 (repo / "worker.py").write_text(f"def work():\n return {i}\n")
630 r = runner.invoke(cli, ["commit", "-m", f"v{i}"])
631 assert r.exit_code == 0, r.output
632
633 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
634 assert result.exit_code == 0
635 data: _RebuildPayload = json.loads(result.output)
636 assert data.get("symbol_history_addresses", 0) > 0
637
638 def test_blob_cache_scales(self, repo: pathlib.Path) -> None:
639 """10 commits on 1 file: blob for each version fetched exactly once."""
640 for i in range(10):
641 (repo / "target.py").write_text(f"def fn():\n return {i}\n")
642 runner.invoke(cli, ["commit", "-m", f"v{i}"])
643
644 original_read = __import__(
645 "muse.core.object_store", fromlist=["read_object"]
646 ).read_object
647 fetch_log: list[str] = []
648
649 def tracked_read(root: pathlib.Path, obj_id: str) -> bytes | None:
650 fetch_log.append(obj_id)
651 result: bytes | None = original_read(root, obj_id)
652 return result
653
654 with mock.patch(
655 "muse.cli.commands.index_rebuild.read_object", side_effect=tracked_read
656 ):
657 _build_symbol_history(repo)
658
659 unique_ids = set(fetch_log)
660 # Every unique obj_id must appear exactly once
661 for obj_id in unique_ids:
662 assert fetch_log.count(obj_id) == 1, (
663 f"obj_id {obj_id[:8]}… fetched {fetch_log.count(obj_id)} times"
664 )
665
666 def test_large_flat_file_hash_occurrence(self, repo: pathlib.Path) -> None:
667 """200 unique functions: no hash_occurrence clusters (all distinct bodies)."""
668 funcs = "\n\n".join(f"def func_{i}():\n return {i}" for i in range(200))
669 (repo / "flat.py").write_text(funcs + "\n")
670 runner.invoke(cli, ["commit", "-m", "flat"])
671 idx = _build_hash_occurrence(repo)
672 # All distinct bodies → no clusters
673 assert len(idx) == 0
674
675 def test_rebuild_performance(self, repo: pathlib.Path) -> None:
676 """20 commits: rebuild must finish within 30 seconds."""
677 for i in range(20):
678 (repo / "perf.py").write_text(f"def work():\n return {i}\n")
679 runner.invoke(cli, ["commit", "-m", f"v{i}"])
680
681 start = time.monotonic()
682 result = runner.invoke(cli, ["code", "index", "rebuild"])
683 elapsed = time.monotonic() - start
684 assert result.exit_code == 0
685 assert elapsed < 30.0, f"rebuild took {elapsed:.1f}s — too slow"
File History 1 commit