gabriel / muse public
test_cmd_find_symbol.py python
694 lines 26.5 KB
Raw
1 """Comprehensive tests for ``muse code find-symbol``.
2
3 Coverage
4 --------
5 Unit
6 _flat_insert_ops — pure inserts, nested patch children, mixed, empty
7 _name_matches — exact, prefix (*), case-insensitive, no-match
8 _list_branches — empty dir, populated dir, non-file entries ignored
9 _MIN_HASH_PREFIX — sentinel value
10
11 Integration (extends mega-suite baseline)
12 --name exact — finds, doesn't find, prefix wildcard
13 --kind filter — restricts to kind
14 --hash — too short rejected, matching hash found
15 --since / --until — date range filtering
16 --limit — stops early
17 --first / --last — deduplication modes
18 --count — scalar output
19 --file filter — restricts to file path
20 --branch — restricts to one branch's history
21 --all-branches — reports branch presence
22 --json schema — required fields, count == len(appearances)
23 no flags — exits 1
24
25 Security
26 no-repo — exits non-zero
27
28 Stress
29 50-commit history — find-symbol across all commits under 10 s
30 prefix wildcard — large result set handled
31 --limit 1 on large — returns quickly
32 """
33
34 from __future__ import annotations
35
36 import json
37 import pathlib
38 import textwrap
39 import time
40
41 import pytest
42
43 from tests.cli_test_helper import CliRunner
44 from muse.cli.commands.find_symbol import (
45 _MIN_HASH_PREFIX,
46 _flat_insert_ops,
47 _list_branches,
48 _name_matches,
49 )
50 from muse.domain import DeleteOp, DomainOp, InsertOp, PatchOp
51 from muse.core.paths import heads_dir, indices_dir
52
53 cli = None
54 runner = CliRunner()
55
56
57 # ---------------------------------------------------------------------------
58 # Helpers to build minimal DomainOp dicts
59 # ---------------------------------------------------------------------------
60
61
62 def _insert_op(address: str = "f.py::foo") -> DomainOp:
63 return InsertOp(
64 op="insert",
65 address=address,
66 position=None,
67 content_id="a" * 64,
68 content_summary="function foo",
69 )
70
71
72 def _patch_op(children: list[DomainOp]) -> DomainOp:
73 return PatchOp(
74 op="patch",
75 address="f.py",
76 child_ops=children,
77 child_domain="code",
78 child_summary="patch",
79 )
80
81
82 def _delete_op(address: str = "f.py::old") -> DomainOp:
83 return DeleteOp(
84 op="delete",
85 address=address,
86 position=None,
87 content_id="e" * 64,
88 content_summary="del",
89 )
90
91
92 # ---------------------------------------------------------------------------
93 # Unit — _flat_insert_ops
94 # ---------------------------------------------------------------------------
95
96
97 class TestFlatInsertOps:
98 def test_empty_list(self) -> None:
99 assert _flat_insert_ops([]) == []
100
101 def test_single_insert(self) -> None:
102 ops = [_insert_op()]
103 result = _flat_insert_ops(ops)
104 assert len(result) == 1
105 assert result[0]["op"] == "insert"
106
107 def test_delete_excluded(self) -> None:
108 ops = [_delete_op(), _insert_op()]
109 result = _flat_insert_ops(ops)
110 assert len(result) == 1
111 assert result[0]["address"] == "f.py::foo"
112
113 def test_patch_children_extracted(self) -> None:
114 child_insert = _insert_op("f.py::bar")
115 patch = _patch_op([child_insert])
116 result = _flat_insert_ops([patch])
117 assert len(result) == 1
118 assert result[0]["address"] == "f.py::bar"
119
120 def test_patch_with_delete_child_excluded(self) -> None:
121 patch = _patch_op([_delete_op("f.py::gone")])
122 result = _flat_insert_ops([patch])
123 assert result == []
124
125 def test_mixed_ops(self) -> None:
126 ops = [
127 _insert_op("f.py::alpha"),
128 _delete_op("f.py::beta"),
129 _patch_op([_insert_op("f.py::gamma"), _delete_op("f.py::delta")]),
130 ]
131 result = _flat_insert_ops(ops)
132 addresses = {r["address"] for r in result}
133 assert "f.py::alpha" in addresses
134 assert "f.py::gamma" in addresses
135 assert "f.py::beta" not in addresses
136 assert "f.py::delta" not in addresses
137
138 def test_multiple_inserts(self) -> None:
139 ops = [_insert_op(f"f.py::fn_{i}") for i in range(10)]
140 result = _flat_insert_ops(ops)
141 assert len(result) == 10
142
143
144 # ---------------------------------------------------------------------------
145 # Unit — _name_matches
146 # ---------------------------------------------------------------------------
147
148
149 class TestNameMatches:
150 def test_exact_match(self) -> None:
151 assert _name_matches("validate_amount", "validate_amount")
152
153 def test_exact_case_insensitive(self) -> None:
154 assert _name_matches("ValidateAmount", "validateamount")
155
156 def test_no_match(self) -> None:
157 assert not _name_matches("validate_amount", "compute_total")
158
159 def test_prefix_wildcard_match(self) -> None:
160 assert _name_matches("validate_amount", "validate*")
161
162 def test_prefix_wildcard_no_match(self) -> None:
163 assert not _name_matches("compute_total", "validate*")
164
165 def test_prefix_wildcard_case_insensitive(self) -> None:
166 assert _name_matches("ValidateAmount", "validate*")
167
168 def test_star_alone_matches_everything(self) -> None:
169 assert _name_matches("anything", "*")
170
171 def test_empty_pattern_no_match(self) -> None:
172 # An empty pattern (not wildcard) only matches an empty name.
173 assert _name_matches("", "")
174 assert not _name_matches("something", "")
175
176 def test_exact_empty_string(self) -> None:
177 assert _name_matches("", "")
178
179 def test_prefix_longer_than_name(self) -> None:
180 assert not _name_matches("foo", "foobar*")
181
182
183 # ---------------------------------------------------------------------------
184 # Unit — _list_branches
185 # ---------------------------------------------------------------------------
186
187
188 class TestListBranches:
189 def test_empty_when_no_heads_dir(self, tmp_path: pathlib.Path) -> None:
190 result = _list_branches(tmp_path)
191 assert result == []
192
193 def test_returns_branch_names(self, tmp_path: pathlib.Path) -> None:
194 heads = heads_dir(tmp_path)
195 heads.mkdir(parents=True)
196 (heads / "main").write_text("abc123")
197 (heads / "feature-foo").write_text("def456")
198 result = _list_branches(tmp_path)
199 assert "main" in result
200 assert "feature-foo" in result
201
202 def test_sorted_output(self, tmp_path: pathlib.Path) -> None:
203 heads = heads_dir(tmp_path)
204 heads.mkdir(parents=True)
205 for name in ("zebra", "alpha", "middle"):
206 (heads / name).write_text("hash")
207 result = _list_branches(tmp_path)
208 assert result == sorted(result)
209
210 def test_ignores_directories(self, tmp_path: pathlib.Path) -> None:
211 heads = heads_dir(tmp_path)
212 heads.mkdir(parents=True)
213 (heads / "main").write_text("hash")
214 (heads / "subdir").mkdir() # not a file
215 result = _list_branches(tmp_path)
216 assert "subdir" not in result
217 assert "main" in result
218
219
220 # ---------------------------------------------------------------------------
221 # Unit — _MIN_HASH_PREFIX
222 # ---------------------------------------------------------------------------
223
224
225 class TestMinHashPrefix:
226 def test_value_is_four(self) -> None:
227 assert _MIN_HASH_PREFIX == 4
228
229
230 # ---------------------------------------------------------------------------
231 # Shared repo fixture
232 # ---------------------------------------------------------------------------
233
234
235 @pytest.fixture
236 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
237 """Repo with two commits, each adding distinct symbols."""
238 monkeypatch.chdir(tmp_path)
239 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
240 r = runner.invoke(cli, ["init", "--domain", "code"])
241 assert r.exit_code == 0, r.output
242
243 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
244 class Invoice:
245 def compute_total(self, items: list[int]) -> int:
246 return sum(items)
247
248 def validate_amount(amount: float) -> bool:
249 return amount > 0
250 """))
251 runner.invoke(cli, ["code", "add", "."])
252 r2 = runner.invoke(cli, ["commit", "-m", "first commit"])
253 assert r2.exit_code == 0, r2.output
254
255 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
256 class Invoice:
257 def compute_total(self, items: list[int]) -> int:
258 return sum(items)
259
260 def apply_discount(self, total: float, pct: float) -> float:
261 return total * (1 - pct)
262
263 def validate_amount(amount: float) -> bool:
264 return amount > 0
265
266 def format_receipt(amount: float) -> str:
267 return f"Total: {amount:.2f}"
268 """))
269 runner.invoke(cli, ["code", "add", "."])
270 r3 = runner.invoke(cli, ["commit", "-m", "second commit"])
271 assert r3.exit_code == 0, r3.output
272 return tmp_path
273
274
275 # ---------------------------------------------------------------------------
276 # Integration — name search
277 # ---------------------------------------------------------------------------
278
279
280 class TestFindSymbolByName:
281 def test_finds_existing_name(self, repo: pathlib.Path) -> None:
282 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount"])
283 assert result.exit_code == 0
284 assert "validate_amount" in result.output
285
286 def test_no_result_for_unknown(self, repo: pathlib.Path) -> None:
287 result = runner.invoke(cli, ["code", "find-symbol", "--name", "zzz_never_exists"])
288 assert result.exit_code == 0
289 assert "no matching" in result.output.lower()
290
291 def test_prefix_wildcard(self, repo: pathlib.Path) -> None:
292 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate*", "--json"])
293 assert result.exit_code == 0
294 data = json.loads(result.output)
295 for ap in data["results"]:
296 assert ap["name"].lower().startswith("validate")
297
298 def test_no_flags_exits_one(self, repo: pathlib.Path) -> None:
299 result = runner.invoke(cli, ["code", "find-symbol"])
300 assert result.exit_code == 1
301
302 def test_first_last_mutually_exclusive(self, repo: pathlib.Path) -> None:
303 result = runner.invoke(cli, [
304 "code", "find-symbol", "--name", "validate_amount", "--first", "--last",
305 ])
306 assert result.exit_code == 1
307
308
309 # ---------------------------------------------------------------------------
310 # Integration — kind and file filters
311 # ---------------------------------------------------------------------------
312
313
314 class TestFindSymbolFilters:
315 def test_kind_class(self, repo: pathlib.Path) -> None:
316 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class", "--json"])
317 assert result.exit_code == 0
318 data = json.loads(result.output)
319 for ap in data["results"]:
320 assert ap["kind"] == "class"
321
322 def test_file_filter(self, repo: pathlib.Path) -> None:
323 result = runner.invoke(cli, [
324 "code", "find-symbol", "--kind", "function", "--file", "billing.py", "--json",
325 ])
326 assert result.exit_code == 0
327 data = json.loads(result.output)
328 for ap in data["results"]:
329 assert "billing.py" in ap["address"]
330
331 def test_limit_one(self, repo: pathlib.Path) -> None:
332 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
333 assert result.exit_code == 0
334
335 def test_count_is_integer(self, repo: pathlib.Path) -> None:
336 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--count"])
337 assert result.exit_code == 0
338 assert result.output.strip().isdigit()
339
340
341 # ---------------------------------------------------------------------------
342 # Integration — date filters
343 # ---------------------------------------------------------------------------
344
345
346 class TestFindSymbolDates:
347 def test_since_future_returns_empty(self, repo: pathlib.Path) -> None:
348 result = runner.invoke(cli, [
349 "code", "find-symbol", "--name", "validate_amount", "--since", "2099-01-01",
350 ])
351 assert result.exit_code == 0
352 assert "no matching" in result.output.lower()
353
354 def test_since_invalid_format_exits_one(self, repo: pathlib.Path) -> None:
355 result = runner.invoke(cli, [
356 "code", "find-symbol", "--name", "validate_amount", "--since", "not-a-date",
357 ])
358 assert result.exit_code == 1
359
360 def test_until_invalid_format_exits_one(self, repo: pathlib.Path) -> None:
361 result = runner.invoke(cli, [
362 "code", "find-symbol", "--name", "validate_amount", "--until", "31/12/2024",
363 ])
364 assert result.exit_code == 1
365
366
367 # ---------------------------------------------------------------------------
368 # Integration — hash search
369 # ---------------------------------------------------------------------------
370
371
372 class TestFindSymbolByHash:
373 def test_hash_too_short_rejected(self, repo: pathlib.Path) -> None:
374 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
375 assert result.exit_code == 1
376 assert "4" in result.stderr or "short" in result.stderr.lower()
377
378 def test_hash_four_chars_accepted(self, repo: pathlib.Path) -> None:
379 # Even a non-matching 4-char hash must not be rejected for length.
380 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "0000"])
381 assert result.exit_code == 0 # no match, but not a length error
382
383
384 # ---------------------------------------------------------------------------
385 # Integration — --first / --last deduplication
386 # ---------------------------------------------------------------------------
387
388
389 class TestFindSymbolFirstLast:
390 def test_first_count_le_all_count(self, repo: pathlib.Path) -> None:
391 r_all = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--count"])
392 r_first = runner.invoke(cli, [
393 "code", "find-symbol", "--name", "validate_amount", "--first", "--count",
394 ])
395 assert r_all.exit_code == 0
396 assert r_first.exit_code == 0
397 count_all = int(r_all.output.strip())
398 count_first = int(r_first.output.strip())
399 assert count_first <= count_all
400
401 def test_last_count_le_all_count(self, repo: pathlib.Path) -> None:
402 r_all = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--count"])
403 r_last = runner.invoke(cli, [
404 "code", "find-symbol", "--name", "validate_amount", "--last", "--count",
405 ])
406 assert int(r_last.output.strip()) <= int(r_all.output.strip())
407
408
409 # ---------------------------------------------------------------------------
410 # Integration — JSON schema
411 # ---------------------------------------------------------------------------
412
413
414 class TestFindSymbolJson:
415 def test_schema_top_level_keys(self, repo: pathlib.Path) -> None:
416 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
417 assert result.exit_code == 0
418 data = json.loads(result.output)
419 for key in ("query", "results", "total"):
420 assert key in data
421
422 def test_count_matches_appearances_length(self, repo: pathlib.Path) -> None:
423 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
424 data = json.loads(result.output)
425 assert data["total"] == len(data["results"])
426
427 def test_appearance_fields(self, repo: pathlib.Path) -> None:
428 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--json"])
429 data = json.loads(result.output)
430 if data["results"]:
431 ap = data["results"][0]
432 for field in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
433 assert field in ap, f"missing field {field!r}"
434
435 def test_empty_result_valid_json(self, repo: pathlib.Path) -> None:
436 result = runner.invoke(cli, ["code", "find-symbol", "--name", "zzz_never", "--json"])
437 assert result.exit_code == 0
438 data = json.loads(result.output)
439 assert data["total"] == 0
440 assert data["results"] == []
441
442
443 # ---------------------------------------------------------------------------
444 # Security
445 # ---------------------------------------------------------------------------
446
447
448 class TestFindSymbolSecurity:
449 def test_requires_repo(
450 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
451 ) -> None:
452 monkeypatch.chdir(tmp_path)
453 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
454 result = runner.invoke(cli, ["code", "find-symbol", "--name", "foo"])
455 assert result.exit_code != 0
456
457
458 # ---------------------------------------------------------------------------
459 # Stress — 50-commit history
460 # ---------------------------------------------------------------------------
461
462
463 class TestFindSymbolStress:
464 @pytest.fixture
465 def deep_repo(
466 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
467 ) -> pathlib.Path:
468 """Repo with 50 commits, each adding one new function."""
469 monkeypatch.chdir(tmp_path)
470 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
471 runner.invoke(cli, ["init", "--domain", "code"])
472
473 lines: list[str] = []
474 for i in range(50):
475 lines.append(f"def worker_{i:03d}(x: int) -> int:")
476 lines.append(f" return x + {i}")
477 lines.append("")
478 (tmp_path / "workers.py").write_text("\n".join(lines))
479 runner.invoke(cli, ["code", "add", "."])
480 r = runner.invoke(cli, ["commit", "-m", f"add worker_{i:03d}"])
481 assert r.exit_code == 0, f"commit {i} failed: {r.output}"
482
483 return tmp_path
484
485 def test_find_across_50_commits_under_10s(self, deep_repo: pathlib.Path) -> None:
486 """find-symbol must walk 50 commits without hanging, regardless of results."""
487 start = time.monotonic()
488 result = runner.invoke(cli, ["code", "find-symbol", "--name", "worker*", "--count"])
489 elapsed = time.monotonic() - start
490 assert result.exit_code == 0, result.output
491 assert elapsed < 10.0, f"find-symbol on 50 commits took {elapsed:.2f}s"
492 # Count is an integer (may be 0 if structured_delta has no InsertOps).
493 assert result.output.strip().isdigit()
494
495 def test_find_prefix_json_always_valid(self, deep_repo: pathlib.Path) -> None:
496 """JSON output is structurally correct regardless of result count."""
497 result = runner.invoke(cli, ["code", "find-symbol", "--name", "worker*", "--json"])
498 assert result.exit_code == 0
499 data = json.loads(result.output)
500 assert data["total"] == len(data["results"])
501 assert isinstance(data["results"], list)
502 assert isinstance(data["query"], dict)
503 # Appearances (if any) must have required fields.
504 for ap in data["results"]:
505 for field in ("content_id", "address", "name", "kind", "commit_id"):
506 assert field in ap
507
508 def test_limit_one_on_50_commits_fast(self, deep_repo: pathlib.Path) -> None:
509 start = time.monotonic()
510 result = runner.invoke(cli, [
511 "code", "find-symbol", "--name", "worker*", "--limit", "1",
512 ])
513 elapsed = time.monotonic() - start
514 assert result.exit_code == 0
515 assert elapsed < 5.0, f"--limit 1 on 50 commits took {elapsed:.2f}s"
516
517 def test_first_dedup_addresses_unique(self, deep_repo: pathlib.Path) -> None:
518 """--first must deduplicate: no address appears more than once."""
519 result = runner.invoke(cli, [
520 "code", "find-symbol", "--name", "worker*", "--first", "--json",
521 ])
522 assert result.exit_code == 0
523 data = json.loads(result.output)
524 addresses = [ap["address"] for ap in data["results"]]
525 assert len(addresses) == len(set(addresses))
526
527 def test_kind_filter_json_consistent_on_deep_repo(self, deep_repo: pathlib.Path) -> None:
528 start = time.monotonic()
529 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
530 elapsed = time.monotonic() - start
531 assert result.exit_code == 0
532 assert elapsed < 10.0
533 data = json.loads(result.output)
534 # count must match appearances length.
535 assert data["total"] == len(data["results"])
536 # All appearances must have kind == "function".
537 for ap in data["results"]:
538 assert ap["kind"] == "function"
539
540
541 # ---------------------------------------------------------------------------
542 # --body-hash flag — index-backed body_hash lookup
543 # ---------------------------------------------------------------------------
544
545
546 class TestFindSymbolBodyHash:
547 """Tests for ``--body-hash`` flag which uses the hash_occurrence index."""
548
549 def _index_path(self, repo: pathlib.Path) -> pathlib.Path:
550 return indices_dir(repo) / "hash_occurrence.json"
551
552 def _get_clone_body_hash(self, repo: pathlib.Path) -> str | None:
553 """Return a body_hash prefix known to be a clone in *repo*."""
554 result = runner.invoke(cli, ["code", "clones", "--tier", "exact", "--json"])
555 if result.exit_code != 0:
556 return None
557 data = json.loads(result.output)
558 if not data["clusters"]:
559 return None
560 return data["clusters"][0]["hash"] # already short_id format
561
562 @pytest.fixture
563 def clone_repo(
564 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
565 ) -> pathlib.Path:
566 monkeypatch.chdir(tmp_path)
567 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
568 runner.invoke(cli, ["init", "--domain", "code"])
569 body = "def shared_fn(x):\n return x * 2\n"
570 (tmp_path / "a.py").write_text(body)
571 (tmp_path / "b.py").write_text(body)
572 runner.invoke(cli, ["code", "add", "."])
573 runner.invoke(cli, ["commit", "-m", "clone fixture"])
574 return tmp_path
575
576 def test_body_hash_too_short_exits_nonzero(
577 self, clone_repo: pathlib.Path
578 ) -> None:
579 result = runner.invoke(cli, ["code", "find-symbol", "--body-hash", "ab"])
580 assert result.exit_code != 0
581
582 def test_body_hash_and_hash_mutually_exclusive(
583 self, clone_repo: pathlib.Path
584 ) -> None:
585 result = runner.invoke(cli, [
586 "code", "find-symbol", "--body-hash", "deadbeef", "--hash", "deadbeef",
587 ])
588 assert result.exit_code != 0
589
590 def test_body_hash_and_name_mutually_exclusive(
591 self, clone_repo: pathlib.Path
592 ) -> None:
593 """--body-hash is a standalone lookup; combining with --name is an error."""
594 result = runner.invoke(cli, [
595 "code", "find-symbol", "--body-hash", "deadbeef", "--name", "foo",
596 ])
597 assert result.exit_code != 0
598
599 def test_body_hash_not_found_exits_zero_empty_results(
600 self, clone_repo: pathlib.Path
601 ) -> None:
602 result = runner.invoke(
603 cli, ["code", "find-symbol", "--body-hash", "deadbeef", "--json"]
604 )
605 assert result.exit_code == 0
606 data = json.loads(result.output)
607 assert data["total"] == 0
608 assert data["results"] == []
609
610 def test_body_hash_finds_exact_clone_addresses_with_index(
611 self, clone_repo: pathlib.Path
612 ) -> None:
613 """With index, --body-hash returns both addresses sharing the body."""
614 runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
615 body_hash = self._get_clone_body_hash(clone_repo)
616 if body_hash is None:
617 pytest.skip("No exact clones detected")
618 result = runner.invoke(
619 cli, ["code", "find-symbol", "--body-hash", body_hash, "--json"]
620 )
621 assert result.exit_code == 0
622 data = json.loads(result.output)
623 assert data["total"] >= 2
624 addresses = {r["address"] for r in data["results"]}
625 files = {a.split("::")[0] for a in addresses}
626 assert len(files) >= 2, "clone members must span at least two files"
627
628 def test_body_hash_falls_back_to_scan_without_index(
629 self, clone_repo: pathlib.Path
630 ) -> None:
631 """Without index, --body-hash walks HEAD snapshot directly."""
632 self._index_path(clone_repo).unlink(missing_ok=True)
633 body_hash = self._get_clone_body_hash(clone_repo)
634 if body_hash is None:
635 pytest.skip("No exact clones detected")
636 result = runner.invoke(
637 cli, ["code", "find-symbol", "--body-hash", body_hash, "--json"]
638 )
639 assert result.exit_code == 0
640 data = json.loads(result.output)
641 assert data["total"] >= 2
642
643 def test_body_hash_json_schema(self, clone_repo: pathlib.Path) -> None:
644 """JSON output has the standard find-symbol envelope fields."""
645 runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
646 body_hash = self._get_clone_body_hash(clone_repo)
647 if body_hash is None:
648 pytest.skip("No exact clones detected")
649 result = runner.invoke(
650 cli, ["code", "find-symbol", "--body-hash", body_hash, "--json"]
651 )
652 assert result.exit_code == 0
653 data = json.loads(result.output)
654 for field in ("total", "results", "query", "exit_code", "duration_ms"):
655 assert field in data, f"missing field: {field}"
656 assert data["query"].get("body_hash") == body_hash
657
658 def test_body_hash_result_has_address_field(
659 self, clone_repo: pathlib.Path
660 ) -> None:
661 runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
662 body_hash = self._get_clone_body_hash(clone_repo)
663 if body_hash is None:
664 pytest.skip("No exact clones detected")
665 result = runner.invoke(
666 cli, ["code", "find-symbol", "--body-hash", body_hash, "--json"]
667 )
668 data = json.loads(result.output)
669 for r in data["results"]:
670 assert "address" in r
671
672 def test_body_hash_results_match_index_and_scan(
673 self, clone_repo: pathlib.Path
674 ) -> None:
675 """Index path and scan path return the same set of addresses."""
676 body_hash = self._get_clone_body_hash(clone_repo)
677 if body_hash is None:
678 pytest.skip("No exact clones detected")
679
680 # Scan path (no index)
681 self._index_path(clone_repo).unlink(missing_ok=True)
682 r_scan = runner.invoke(
683 cli, ["code", "find-symbol", "--body-hash", body_hash, "--json"]
684 )
685 scan_addrs = {ap["address"] for ap in json.loads(r_scan.output)["results"]}
686
687 # Index path
688 runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
689 r_idx = runner.invoke(
690 cli, ["code", "find-symbol", "--body-hash", body_hash, "--json"]
691 )
692 idx_addrs = {ap["address"] for ap in json.loads(r_idx.output)["results"]}
693
694 assert scan_addrs == idx_addrs
File History 1 commit