gabriel / muse public
test_cmd_index.py python
716 lines 29.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
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 payload = json.loads(result.output)
129 out: list[_StatusEntry] = payload["indexes"]
130 return out
131
132
133 # ---------------------------------------------------------------------------
134 # Fixtures
135 # ---------------------------------------------------------------------------
136
137
138 @pytest.fixture
139 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
140 monkeypatch.chdir(tmp_path)
141 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
142 result = runner.invoke(cli, ["init", "--domain", "code"])
143 assert result.exit_code == 0, result.output
144 return tmp_path
145
146
147 @pytest.fixture
148 def two_commit_repo(repo: pathlib.Path) -> pathlib.Path:
149 """Repo with two commits: v1 has one function, v2 replaces it."""
150 (repo / "billing.py").write_text(textwrap.dedent("""\
151 def compute(items):
152 return sum(items)
153 """))
154 r1 = runner.invoke(cli, ["commit", "-m", "v1"])
155 assert r1.exit_code == 0, r1.output
156
157 (repo / "billing.py").write_text(textwrap.dedent("""\
158 def compute(items):
159 return sum(items) * 2
160 """))
161 r2 = runner.invoke(cli, ["commit", "-m", "v2"])
162 assert r2.exit_code == 0, r2.output
163 return repo
164
165
166 @pytest.fixture
167 def clone_repo(repo: pathlib.Path) -> pathlib.Path:
168 """Repo with two files containing identical body → one hash_occurrence cluster."""
169 body = "def helper():\n return True\n"
170 (repo / "a.py").write_text(body)
171 (repo / "b.py").write_text(body + "\ndef other():\n pass\n")
172 runner.invoke(cli, ["commit", "-m", "clones"])
173 return repo
174
175
176 # ---------------------------------------------------------------------------
177 # Unit — _build_symbol_history
178 # ---------------------------------------------------------------------------
179
180
181 class TestBuildSymbolHistory:
182 def test_empty_repo_returns_empty(self, repo: pathlib.Path) -> None:
183 idx = _build_symbol_history(repo)
184 assert isinstance(idx, dict)
185 assert len(idx) == 0
186
187 def test_after_commit_has_entries(self, two_commit_repo: pathlib.Path) -> None:
188 idx = _build_symbol_history(two_commit_repo)
189 assert len(idx) > 0
190
191 def test_address_contains_double_colon(self, two_commit_repo: pathlib.Path) -> None:
192 idx = _build_symbol_history(two_commit_repo)
193 assert all("::" in addr for addr in idx)
194
195 def test_entries_are_symbol_history_entry(self, two_commit_repo: pathlib.Path) -> None:
196 idx = _build_symbol_history(two_commit_repo)
197 for entries in idx.values():
198 for e in entries:
199 assert isinstance(e, SymbolHistoryEntry)
200
201 def test_missing_manifest_skipped_with_log(
202 self, two_commit_repo: pathlib.Path, caplog: pytest.LogCaptureFixture
203 ) -> None:
204 """Commits with missing snapshot manifests are logged and skipped."""
205 import logging
206 with caplog.at_level(logging.DEBUG, logger="muse.cli.commands.index_rebuild"):
207 with mock.patch(
208 "muse.cli.commands.index_rebuild.get_commit_snapshot_manifest",
209 return_value=None,
210 ):
211 idx = _build_symbol_history(two_commit_repo)
212 # All commits skipped → empty index
213 assert len(idx) == 0
214 assert any("Missing snapshot manifest" in r.message for r in caplog.records)
215
216 def test_manifest_cache_prevents_double_fetch(self, two_commit_repo: pathlib.Path) -> None:
217 """Each unique manifest (commit) is fetched at most once."""
218 original = __import__(
219 "muse.core.store", fromlist=["get_commit_snapshot_manifest"]
220 ).get_commit_snapshot_manifest
221
222 call_counts: _CountMap = {}
223
224 def counting_fetch(root: pathlib.Path, commit_id: str) -> Manifest | None:
225 call_counts[commit_id] = call_counts.get(commit_id, 0) + 1
226 result: Manifest | None = original(root, commit_id)
227 return result
228
229 with mock.patch(
230 "muse.cli.commands.index_rebuild.get_commit_snapshot_manifest",
231 side_effect=counting_fetch,
232 ):
233 _build_symbol_history(two_commit_repo)
234
235 for commit_id, count in call_counts.items():
236 assert count == 1, (
237 f"Manifest for commit {commit_id[:8]} fetched {count} times — expected 1"
238 )
239
240 def test_blob_cache_prevents_double_parse(self, two_commit_repo: pathlib.Path) -> None:
241 """Each unique blob (obj_id) is read at most once within a single run."""
242 original_read = __import__(
243 "muse.core.object_store", fromlist=["read_object"]
244 ).read_object
245
246 obj_fetch_count: _CountMap = {}
247
248 def counting_read(root: pathlib.Path, obj_id: str) -> bytes | None:
249 obj_fetch_count[obj_id] = obj_fetch_count.get(obj_id, 0) + 1
250 result: bytes | None = original_read(root, obj_id)
251 return result
252
253 with mock.patch(
254 "muse.cli.commands.index_rebuild.read_object",
255 side_effect=counting_read,
256 ):
257 _build_symbol_history(two_commit_repo)
258
259 duplicates = {oid: n for oid, n in obj_fetch_count.items() if n > 1}
260 assert not duplicates, (
261 f"Blobs fetched more than once: {duplicates} — blob_cache not working"
262 )
263
264 def test_symbol_cache_consulted_before_read_object(
265 self, two_commit_repo: pathlib.Path
266 ) -> None:
267 """When SymbolCache has a hit, read_object is never called for that obj_id."""
268 from muse.core.object_store import read_object as real_read
269 from muse.core.store import get_commit_snapshot_manifest
270
271 # Pre-populate a SymbolCache with every blob in every commit's manifest.
272 warm_cache = SymbolCache.empty()
273 from muse.core.store import get_all_commits
274 from muse.plugins.code.ast_parser import parse_symbols as real_parse
275 from muse.plugins.code._query import is_semantic
276
277 for commit in get_all_commits(two_commit_repo):
278 manifest = get_commit_snapshot_manifest(two_commit_repo, commit.commit_id) or {}
279 for fp, oid in manifest.items():
280 if is_semantic(fp) and warm_cache.get(oid) is None:
281 raw = real_read(two_commit_repo, oid)
282 if raw is not None:
283 warm_cache.put(oid, real_parse(raw, fp))
284
285 read_calls: list[str] = []
286
287 def spy_read(root: pathlib.Path, obj_id: str) -> bytes | None:
288 read_calls.append(obj_id)
289 result: bytes | None = real_read(root, obj_id)
290 return result
291
292 with mock.patch("muse.cli.commands.index_rebuild.read_object", side_effect=spy_read):
293 _build_symbol_history(two_commit_repo, symbol_cache=warm_cache)
294
295 assert read_calls == [], (
296 f"read_object called {len(read_calls)} times despite warm SymbolCache"
297 )
298
299 def test_symbol_cache_populated_on_miss(self, two_commit_repo: pathlib.Path) -> None:
300 """A cold SymbolCache is populated during _build_symbol_history."""
301 cold_cache = SymbolCache.empty()
302 assert cold_cache.size == 0
303 _build_symbol_history(two_commit_repo, symbol_cache=cold_cache)
304 # Cache should have been populated with at least one entry.
305 assert cold_cache.size > 0
306
307
308 # ---------------------------------------------------------------------------
309 # Unit — _build_hash_occurrence
310 # ---------------------------------------------------------------------------
311
312
313 class TestBuildHashOccurrence:
314 def test_no_head_returns_empty(self, repo: pathlib.Path) -> None:
315 """No commits → no HEAD ref → gracefully returns empty dict."""
316 idx = _build_hash_occurrence(repo)
317 assert idx == {}
318
319 def test_single_function_not_a_clone(self, repo: pathlib.Path) -> None:
320 (repo / "solo.py").write_text("def unique():\n return 42\n")
321 runner.invoke(cli, ["commit", "-m", "solo"])
322 idx = _build_hash_occurrence(repo)
323 # unique function appears only once → filtered out
324 assert all(len(addrs) > 1 for addrs in idx.values())
325
326 def test_identical_bodies_form_cluster(self, clone_repo: pathlib.Path) -> None:
327 idx = _build_hash_occurrence(clone_repo)
328 assert len(idx) > 0
329 # every cluster has ≥ 2 members
330 assert all(len(addrs) >= 2 for addrs in idx.values())
331
332 def test_imports_excluded(self, repo: pathlib.Path) -> None:
333 (repo / "mod.py").write_text("import os\nimport sys\ndef fn():\n return 1\n")
334 runner.invoke(cli, ["commit", "-m", "imports"])
335 idx = _build_hash_occurrence(repo)
336 for addrs in idx.values():
337 for addr in addrs:
338 assert "::import::" not in addr
339
340 def test_missing_manifest_returns_empty(self, two_commit_repo: pathlib.Path) -> None:
341 with mock.patch(
342 "muse.cli.commands.index_rebuild.get_commit_snapshot_manifest",
343 return_value=None,
344 ):
345 idx = _build_hash_occurrence(two_commit_repo)
346 assert idx == {}
347
348
349 # ---------------------------------------------------------------------------
350 # Unit — index_info and purge_index
351 # ---------------------------------------------------------------------------
352
353
354 class TestIndexInfo:
355 def test_absent_before_rebuild(self, repo: pathlib.Path) -> None:
356 infos = index_info(repo)
357 assert len(infos) == len(KNOWN_INDEX_NAMES)
358 for info in infos:
359 assert info["status"] == "absent"
360
361 def test_entries_is_int_not_str(self, repo: pathlib.Path) -> None:
362 infos = index_info(repo)
363 for info in infos:
364 assert isinstance(info["entries"], int), (
365 f"{info['name']}.entries should be int, got {type(info['entries'])}"
366 )
367
368 def test_present_after_rebuild(self, two_commit_repo: pathlib.Path) -> None:
369 runner.invoke(cli, ["code", "index", "rebuild"])
370 infos = index_info(two_commit_repo)
371 for info in infos:
372 assert info["status"] == "present"
373
374 def test_corrupt_index_reported(self, repo: pathlib.Path) -> None:
375 (repo / ".muse" / "indices").mkdir(parents=True, exist_ok=True)
376 _index_path(repo, "symbol_history").write_bytes(b"\xff\xfe corrupt garbage")
377 infos = index_info(repo)
378 sym = next(i for i in infos if i["name"] == "symbol_history")
379 assert sym["status"] == "corrupt"
380
381 def test_updated_at_is_none_when_absent(self, repo: pathlib.Path) -> None:
382 infos = index_info(repo)
383 for info in infos:
384 assert info["updated_at"] is None
385
386
387 class TestPurgeIndex:
388 def test_purge_existing_returns_true(self, two_commit_repo: pathlib.Path) -> None:
389 runner.invoke(cli, ["code", "index", "rebuild"])
390 assert _index_exists(two_commit_repo, "symbol_history")
391 result = purge_index(two_commit_repo, "symbol_history")
392 assert result is True
393 assert not _index_exists(two_commit_repo, "symbol_history")
394
395 def test_purge_absent_returns_false(self, repo: pathlib.Path) -> None:
396 result = purge_index(repo, "hash_occurrence")
397 assert result is False
398
399 def test_purge_unknown_name_raises(self, repo: pathlib.Path) -> None:
400 with pytest.raises(ValueError, match="Unknown index name"):
401 purge_index(repo, "nonexistent_index")
402
403
404 # ---------------------------------------------------------------------------
405 # Integration — CLI runner tests
406 # ---------------------------------------------------------------------------
407
408
409 class TestIndexStatusCLI:
410 def test_exit_zero(self, repo: pathlib.Path) -> None:
411 result = runner.invoke(cli, ["code", "index", "status"])
412 assert result.exit_code == 0
413
414 def test_json_is_list(self, repo: pathlib.Path) -> None:
415 result = runner.invoke(cli, ["code", "index", "status", "--json"])
416 assert result.exit_code == 0
417 payload = json.loads(result.output)
418 data = payload["indexes"]
419 assert isinstance(data, list)
420 assert len(data) == len(KNOWN_INDEX_NAMES)
421
422 def test_json_entry_keys(self, repo: pathlib.Path) -> None:
423 data = _invoke_status_json()
424 for entry in data:
425 for key in ("name", "status", "entries", "updated_at"):
426 assert key in entry, f"Missing key {key!r} in status entry"
427
428 def test_json_entries_is_int(self, repo: pathlib.Path) -> None:
429 data = _invoke_status_json()
430 for entry in data:
431 assert isinstance(entry["entries"], int)
432
433 def test_json_absent_status_before_rebuild(self, repo: pathlib.Path) -> None:
434 data = _invoke_status_json()
435 assert all(e["status"] == "absent" for e in data)
436
437 def test_text_contains_hint_to_rebuild(self, repo: pathlib.Path) -> None:
438 result = runner.invoke(cli, ["code", "index", "status"])
439 assert "muse code index rebuild" in result.output
440
441 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
442 monkeypatch.chdir(tmp_path)
443 result = runner.invoke(cli, ["code", "index", "status"])
444 assert result.exit_code != 0
445
446
447 class TestIndexRebuildCLI:
448 def test_exit_zero(self, two_commit_repo: pathlib.Path) -> None:
449 result = runner.invoke(cli, ["code", "index", "rebuild"])
450 assert result.exit_code == 0
451
452 def test_json_top_level_keys(self, two_commit_repo: pathlib.Path) -> None:
453 data = _invoke_rebuild_json()
454 for key in ("schema", "dry_run", "rebuilt",
455 "symbol_history_addresses", "symbol_history_events",
456 "hash_occurrence_clusters", "hash_occurrence_addresses"):
457 assert key in data, f"Missing key {key!r}"
458
459 def test_json_dry_run_false_by_default(self, two_commit_repo: pathlib.Path) -> None:
460 data = _invoke_rebuild_json()
461 assert data["dry_run"] is False
462
463 def test_json_rebuilt_contains_both(self, two_commit_repo: pathlib.Path) -> None:
464 data = _invoke_rebuild_json()
465 assert set(data["rebuilt"]) == set(KNOWN_INDEX_NAMES)
466
467 def test_rebuild_writes_files(self, two_commit_repo: pathlib.Path) -> None:
468 runner.invoke(cli, ["code", "index", "rebuild"])
469 assert _index_exists(two_commit_repo, "symbol_history")
470 assert _index_exists(two_commit_repo, "hash_occurrence")
471
472 def test_dry_run_no_files_written(self, two_commit_repo: pathlib.Path) -> None:
473 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
474 assert result.exit_code == 0
475 assert not _index_exists(two_commit_repo, "symbol_history")
476 assert not _index_exists(two_commit_repo, "hash_occurrence")
477
478 def test_dry_run_json_flag(self, two_commit_repo: pathlib.Path) -> None:
479 data = _invoke_rebuild_json(["--dry-run"])
480 assert data["dry_run"] is True
481
482 def test_dry_run_counts_match_real_rebuild(self, two_commit_repo: pathlib.Path) -> None:
483 dry = _invoke_rebuild_json(["--dry-run"])
484 real = _invoke_rebuild_json()
485 assert dry["symbol_history_addresses"] == real["symbol_history_addresses"]
486 assert dry["symbol_history_events"] == real["symbol_history_events"]
487 assert dry["hash_occurrence_clusters"] == real["hash_occurrence_clusters"]
488
489 def test_index_symbol_history_only(self, two_commit_repo: pathlib.Path) -> None:
490 data = _invoke_rebuild_json(["--index", "symbol_history"])
491 assert data["rebuilt"] == ["symbol_history"]
492 assert _index_exists(two_commit_repo, "symbol_history")
493 assert not _index_exists(two_commit_repo, "hash_occurrence")
494
495 def test_index_hash_occurrence_only(self, two_commit_repo: pathlib.Path) -> None:
496 data = _invoke_rebuild_json(["--index", "hash_occurrence"])
497 assert data["rebuilt"] == ["hash_occurrence"]
498 assert not _index_exists(two_commit_repo, "symbol_history")
499 assert _index_exists(two_commit_repo, "hash_occurrence")
500
501 def test_text_output_no_files_on_dry_run(self, two_commit_repo: pathlib.Path) -> None:
502 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
503 assert "dry run" in result.output.lower()
504
505 def test_text_output_rebuild_references_status(self, two_commit_repo: pathlib.Path) -> None:
506 result = runner.invoke(cli, ["code", "index", "rebuild"])
507 assert "muse code index status" in result.output
508
509 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
510 monkeypatch.chdir(tmp_path)
511 result = runner.invoke(cli, ["code", "index", "rebuild"])
512 assert result.exit_code != 0
513
514
515 class TestIndexPurgeCLI:
516 def test_exit_zero(self, two_commit_repo: pathlib.Path) -> None:
517 runner.invoke(cli, ["code", "index", "rebuild"])
518 result = runner.invoke(cli, ["code", "index", "purge"])
519 assert result.exit_code == 0
520
521 def test_json_schema(self, two_commit_repo: pathlib.Path) -> None:
522 runner.invoke(cli, ["code", "index", "rebuild"])
523 result = runner.invoke(cli, ["code", "index", "purge", "--json"])
524 assert result.exit_code == 0
525 data: _PurgePayload = json.loads(result.output)
526 assert "schema" in data
527 assert "purged" in data
528 assert "skipped" in data
529
530 def test_purge_all_deletes_files(self, two_commit_repo: pathlib.Path) -> None:
531 runner.invoke(cli, ["code", "index", "rebuild"])
532 runner.invoke(cli, ["code", "index", "purge"])
533 assert not _index_exists(two_commit_repo, "symbol_history")
534 assert not _index_exists(two_commit_repo, "hash_occurrence")
535
536 def test_purge_specific_index(self, two_commit_repo: pathlib.Path) -> None:
537 runner.invoke(cli, ["code", "index", "rebuild"])
538 result = runner.invoke(
539 cli, ["code", "index", "purge", "--index", "symbol_history", "--json"]
540 )
541 data: _PurgePayload = json.loads(result.output)
542 assert "symbol_history" in data["purged"]
543 assert not _index_exists(two_commit_repo, "symbol_history")
544 assert _index_exists(two_commit_repo, "hash_occurrence")
545
546 def test_purge_absent_shows_skipped(self, repo: pathlib.Path) -> None:
547 result = runner.invoke(cli, ["code", "index", "purge", "--json"])
548 data: _PurgePayload = json.loads(result.output)
549 assert data["purged"] == []
550 assert set(data["skipped"]) == set(KNOWN_INDEX_NAMES)
551
552 def test_purge_then_status_absent(self, two_commit_repo: pathlib.Path) -> None:
553 runner.invoke(cli, ["code", "index", "rebuild"])
554 runner.invoke(cli, ["code", "index", "purge"])
555 data = _invoke_status_json()
556 assert all(e["status"] == "absent" for e in data)
557
558 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
559 monkeypatch.chdir(tmp_path)
560 result = runner.invoke(cli, ["code", "index", "purge"])
561 assert result.exit_code != 0
562
563
564 # ---------------------------------------------------------------------------
565 # E2E — real commit history interactions
566 # ---------------------------------------------------------------------------
567
568
569 class TestIndexE2E:
570 def test_status_shows_present_after_rebuild(self, two_commit_repo: pathlib.Path) -> None:
571 runner.invoke(cli, ["code", "index", "rebuild"])
572 data = _invoke_status_json()
573 for entry in data:
574 assert entry["status"] == "present", f"{entry['name']} still absent"
575
576 def test_status_entries_nonzero_after_rebuild(self, two_commit_repo: pathlib.Path) -> None:
577 runner.invoke(cli, ["code", "index", "rebuild"])
578 data = _invoke_status_json()
579 sym = next(e for e in data if e["name"] == "symbol_history")
580 assert sym["entries"] > 0
581
582 def test_symbol_history_contains_billing_compute(self, two_commit_repo: pathlib.Path) -> None:
583 idx = _build_symbol_history(two_commit_repo)
584 assert any("billing.py::compute" in addr for addr in idx)
585
586 def test_hash_occurrence_cluster_for_clones(self, clone_repo: pathlib.Path) -> None:
587 idx = _build_hash_occurrence(clone_repo)
588 assert len(idx) > 0
589
590 def test_rebuild_is_idempotent(self, two_commit_repo: pathlib.Path) -> None:
591 d1 = _invoke_rebuild_json()
592 d2 = _invoke_rebuild_json()
593 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
594 assert d1["symbol_history_events"] == d2["symbol_history_events"]
595 assert d1["hash_occurrence_clusters"] == d2["hash_occurrence_clusters"]
596
597 def test_purge_then_rebuild_restores_present(self, two_commit_repo: pathlib.Path) -> None:
598 runner.invoke(cli, ["code", "index", "rebuild"])
599 runner.invoke(cli, ["code", "index", "purge"])
600 runner.invoke(cli, ["code", "index", "rebuild"])
601 data = _invoke_status_json()
602 for entry in data:
603 assert entry["status"] == "present"
604
605 def test_purge_index_only_removes_targeted(self, two_commit_repo: pathlib.Path) -> None:
606 runner.invoke(cli, ["code", "index", "rebuild"])
607 runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence"])
608 data = _invoke_status_json()
609 sym = next(e for e in data if e["name"] == "symbol_history")
610 ho = next(e for e in data if e["name"] == "hash_occurrence")
611 assert sym["status"] == "present"
612 assert ho["status"] == "absent"
613
614 def test_dry_run_counts_match_real_rebuild(self, two_commit_repo: pathlib.Path) -> None:
615 dry = _invoke_rebuild_json(["--dry-run"])
616 real = _invoke_rebuild_json()
617 for key in ("symbol_history_addresses", "symbol_history_events",
618 "hash_occurrence_clusters", "hash_occurrence_addresses"):
619 assert dry.get(key) == real.get(key), f"Mismatch on {key}"
620
621
622 # ---------------------------------------------------------------------------
623 # Stress
624 # ---------------------------------------------------------------------------
625
626
627 class TestIndexStress:
628 def test_50_commit_rebuild_completes(self, repo: pathlib.Path) -> None:
629 """50 commits, each changing one function — rebuild must complete."""
630 for i in range(50):
631 (repo / "worker.py").write_text(f"def work():\n return {i}\n")
632 r = runner.invoke(cli, ["commit", "-m", f"v{i}"])
633 assert r.exit_code == 0, r.output
634
635 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
636 assert result.exit_code == 0
637 data: _RebuildPayload = json.loads(result.output)
638 assert data.get("symbol_history_addresses", 0) > 0
639
640 def test_blob_cache_scales(self, repo: pathlib.Path) -> None:
641 """10 commits on 1 file: blob for each version fetched exactly once."""
642 for i in range(10):
643 (repo / "target.py").write_text(f"def fn():\n return {i}\n")
644 runner.invoke(cli, ["commit", "-m", f"v{i}"])
645
646 original_read = __import__(
647 "muse.core.object_store", fromlist=["read_object"]
648 ).read_object
649 fetch_log: list[str] = []
650
651 def tracked_read(root: pathlib.Path, obj_id: str) -> bytes | None:
652 fetch_log.append(obj_id)
653 result: bytes | None = original_read(root, obj_id)
654 return result
655
656 with mock.patch(
657 "muse.cli.commands.index_rebuild.read_object", side_effect=tracked_read
658 ):
659 _build_symbol_history(repo)
660
661 unique_ids = set(fetch_log)
662 # Every unique obj_id must appear exactly once
663 for obj_id in unique_ids:
664 assert fetch_log.count(obj_id) == 1, (
665 f"obj_id {obj_id[:8]}… fetched {fetch_log.count(obj_id)} times"
666 )
667
668 def test_large_flat_file_hash_occurrence(self, repo: pathlib.Path) -> None:
669 """200 unique functions: no hash_occurrence clusters (all distinct bodies)."""
670 funcs = "\n\n".join(f"def func_{i}():\n return {i}" for i in range(200))
671 (repo / "flat.py").write_text(funcs + "\n")
672 runner.invoke(cli, ["commit", "-m", "flat"])
673 idx = _build_hash_occurrence(repo)
674 # All distinct bodies → no clusters
675 assert len(idx) == 0
676
677 def test_rebuild_performance(self, repo: pathlib.Path) -> None:
678 """20 commits: rebuild must finish within 30 seconds."""
679 for i in range(20):
680 (repo / "perf.py").write_text(f"def work():\n return {i}\n")
681 runner.invoke(cli, ["commit", "-m", f"v{i}"])
682
683 start = time.monotonic()
684 result = runner.invoke(cli, ["code", "index", "rebuild"])
685 elapsed = time.monotonic() - start
686 assert result.exit_code == 0
687 assert elapsed < 30.0, f"rebuild took {elapsed:.1f}s — too slow"
688
689
690 class TestRegisterFlags:
691 def test_json_short_flag(self):
692 import argparse
693 from muse.cli.commands.index_rebuild import register
694 p = argparse.ArgumentParser()
695 subs = p.add_subparsers()
696 register(subs)
697 args = p.parse_args(["index", "rebuild", "-j"])
698 assert args.json_out is True
699
700 def test_json_long_flag(self):
701 import argparse
702 from muse.cli.commands.index_rebuild import register
703 p = argparse.ArgumentParser()
704 subs = p.add_subparsers()
705 register(subs)
706 args = p.parse_args(["index", "rebuild", "--json"])
707 assert args.json_out is True
708
709 def test_default_no_json(self):
710 import argparse
711 from muse.cli.commands.index_rebuild import register
712 p = argparse.ArgumentParser()
713 subs = p.add_subparsers()
714 register(subs)
715 args = p.parse_args(["index", "rebuild"])
716 assert args.json_out is False
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago