gabriel / muse public
test_symbols_supercharge.py python
823 lines 36.2 KB
Raw
1 """Seven-tier tests for ``muse/cli/commands/symbols.py``.
2
3 Tiers
4 -----
5 Unit — TypedDict fields; _c colour helper; _normalise_language;
6 _file_matches exact/suffix/separator anchoring; _resolve_file_filter
7 match/miss/ambiguous; _lang_counts; _print_human empty/non-empty;
8 _emit_json structure and field names.
9 Integration — -j alias parity; JSON envelope (schema_version, exit_code,
10 duration_ms); --kind / --language / --file filters in JSON;
11 --hashes in JSON; --count still works alongside envelope.
12 End-to-end — full CLI round-trips: basic, count, json, filters, commit ref,
13 invalid kind, ambiguous file, working-tree vs committed.
14 Stress — 1 000 _file_matches calls; 10 000 _lang_counts calls;
15 _emit_json on 500-symbol map.
16 Data integrity — total_symbols accurate; JSON result order by lineno;
17 schema_version, exit_code, duration_ms types correct;
18 all 9 entry fields present; working_tree bool invariant.
19 Security — ANSI in file path/symbol name; hostile --file value; long
20 language name; null byte in kind.
21 Performance — 10 000 _file_matches under 0.5 s; duration_ms field < 30 000ms.
22 """
23
24 from __future__ import annotations
25 from collections.abc import Mapping
26
27 import json
28 import os
29 import pathlib
30 import textwrap
31 import threading
32 import time
33 from typing import get_type_hints
34
35 import pytest
36
37 from muse.core.types import fake_id
38 from tests.cli_test_helper import CliRunner, InvokeResult
39
40 runner = CliRunner()
41
42
43 # ──────────────────────────────────────────────────────────────────────────────
44 # Fixture helpers
45 # ──────────────────────────────────────────────────────────────────────────────
46
47
48 def _commit(repo: pathlib.Path, files: Mapping[str, str], message: str) -> None:
49 for name, content in files.items():
50 path = repo / name
51 path.parent.mkdir(parents=True, exist_ok=True)
52 path.write_text(content, encoding="utf-8")
53 saved = os.getcwd()
54 try:
55 os.chdir(repo)
56 runner.invoke(None, ["code", "add", "."])
57 runner.invoke(None, ["commit", "-m", message])
58 finally:
59 os.chdir(saved)
60
61
62 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
63 saved = os.getcwd()
64 try:
65 os.chdir(repo)
66 return runner.invoke(None, args)
67 finally:
68 os.chdir(saved)
69
70
71 def _syms(repo: pathlib.Path, *args: str) -> InvokeResult:
72 return _invoke(repo, ["code", "symbols", *args])
73
74
75 @pytest.fixture()
76 def sym_repo(tmp_path: pathlib.Path) -> pathlib.Path:
77 """Repo with two Python files and distinct symbol kinds."""
78 saved = os.getcwd()
79 try:
80 os.chdir(tmp_path)
81 runner.invoke(None, ["init"])
82 finally:
83 os.chdir(saved)
84
85 _commit(tmp_path, {
86 "billing.py": textwrap.dedent("""\
87 class Invoice:
88 def __init__(self, amount):
89 self.amount = amount
90
91 def total(self):
92 return self.amount * 1.1
93
94 def process_order(inv):
95 return inv.total()
96
97 async def send_email(to):
98 pass
99 """),
100 "utils.py": textwrap.dedent("""\
101 def helper():
102 return True
103
104 class Config:
105 debug = False
106 """),
107 }, "feat: add billing and utils")
108
109 return tmp_path
110
111
112 # ──────────────────────────────────────────────────────────────────────────────
113 # Unit — TypedDict
114 # ──────────────────────────────────────────────────────────────────────────────
115
116
117 class TestTypedDict:
118 def test_symbols_json_typeddict_exists(self) -> None:
119 from muse.cli.commands.symbols import _SymbolsJson # noqa: F401
120
121 def test_has_schema_version(self) -> None:
122 from muse.cli.commands.symbols import _SymbolsJson
123 assert "schema" in get_type_hints(_SymbolsJson)
124
125 def test_has_exit_code(self) -> None:
126 from muse.cli.commands.symbols import _SymbolsJson
127 assert "exit_code" in get_type_hints(_SymbolsJson)
128
129 def test_has_duration_ms(self) -> None:
130 from muse.cli.commands.symbols import _SymbolsJson
131 assert "duration_ms" in get_type_hints(_SymbolsJson)
132
133 def test_has_core_fields(self) -> None:
134 from muse.cli.commands.symbols import _SymbolsJson
135 hints = get_type_hints(_SymbolsJson)
136 for field in ("source_ref", "working_tree", "total_symbols", "results"):
137 assert field in hints, f"missing field: {field}"
138
139
140 # ──────────────────────────────────────────────────────────────────────────────
141 # Unit — _c colour helper
142 # ──────────────────────────────────────────────────────────────────────────────
143
144
145 class TestColorHelper:
146 def test_no_tty_returns_plain_text(self) -> None:
147 from muse.cli.commands.symbols import _c, _BLUE
148 assert _c("hello", _BLUE, tty=False) == "hello"
149
150 def test_tty_wraps_with_ansi(self) -> None:
151 from muse.cli.commands.symbols import _c, _BLUE, _RESET
152 result = _c("hello", _BLUE, tty=True)
153 assert _BLUE in result
154 assert _RESET in result
155 assert "hello" in result
156
157 def test_multiple_codes_all_applied(self) -> None:
158 from muse.cli.commands.symbols import _c, _BOLD, _YELLOW, _RESET
159 result = _c("x", _BOLD, _YELLOW, tty=True)
160 assert _BOLD in result
161 assert _YELLOW in result
162
163
164 # ──────────────────────────────────────────────────────────────────────────────
165 # Unit — _normalise_language
166 # ──────────────────────────────────────────────────────────────────────────────
167
168
169 class TestNormaliseLanguage:
170 def test_python_lowercase_normalised(self) -> None:
171 from muse.cli.commands.symbols import _normalise_language
172 assert _normalise_language("python") == "Python"
173
174 def test_python_uppercase_normalised(self) -> None:
175 from muse.cli.commands.symbols import _normalise_language
176 assert _normalise_language("PYTHON") == "Python"
177
178 def test_python_mixed_normalised(self) -> None:
179 from muse.cli.commands.symbols import _normalise_language
180 assert _normalise_language("Python") == "Python"
181
182 def test_unknown_language_returned_unchanged(self) -> None:
183 from muse.cli.commands.symbols import _normalise_language
184 assert _normalise_language("Brainfuck") == "Brainfuck"
185
186 def test_strips_whitespace(self) -> None:
187 from muse.cli.commands.symbols import _normalise_language
188 result = _normalise_language(" python ")
189 assert result == "Python"
190
191
192 # ──────────────────────────────────────────────────────────────────────────────
193 # Unit — _file_matches
194 # ──────────────────────────────────────────────────────────────────────────────
195
196
197 class TestFileMatches:
198 def test_exact_path_matches(self) -> None:
199 from muse.cli.commands.symbols import _file_matches
200 assert _file_matches("src/billing.py", "src/billing.py") is True
201
202 def test_suffix_with_slash_anchor_matches(self) -> None:
203 from muse.cli.commands.symbols import _file_matches
204 assert _file_matches("src/billing.py", "billing.py") is True
205
206 def test_partial_name_does_not_match(self) -> None:
207 """'y.py' must NOT match 'billy.py' — separator anchor required."""
208 from muse.cli.commands.symbols import _file_matches
209 assert _file_matches("billy.py", "y.py") is False
210
211 def test_no_match_returns_false(self) -> None:
212 from muse.cli.commands.symbols import _file_matches
213 assert _file_matches("src/utils.py", "billing.py") is False
214
215 def test_windows_backslash_normalised(self) -> None:
216 """Backslash in the suffix filter is normalised to slash before matching."""
217 from muse.cli.commands.symbols import _file_matches
218 assert _file_matches("a/b/billing.py", "b\\billing.py") is True
219
220 def test_deep_path_suffix_matches(self) -> None:
221 from muse.cli.commands.symbols import _file_matches
222 assert _file_matches("a/b/c/billing.py", "billing.py") is True
223
224
225 # ──────────────────────────────────────────────────────────────────────────────
226 # Unit — _resolve_file_filter
227 # ──────────────────────────────────────────────────────────────────────────────
228
229
230 class TestResolveFileFilter:
231 def _manifest(self, *paths: str) -> Mapping[str, object]:
232 return {p: fake_id("aa") for p in paths}
233
234 def test_exact_match_returns_path(self) -> None:
235 from muse.cli.commands.symbols import _resolve_file_filter
236 result = _resolve_file_filter("billing.py", self._manifest("billing.py"))
237 assert result == "billing.py"
238
239 def test_suffix_match_returns_full_path(self) -> None:
240 from muse.cli.commands.symbols import _resolve_file_filter
241 result = _resolve_file_filter("billing.py", self._manifest("src/billing.py"))
242 assert result == "src/billing.py"
243
244 def test_no_match_returns_none(self) -> None:
245 from muse.cli.commands.symbols import _resolve_file_filter
246 result = _resolve_file_filter("nope.py", self._manifest("billing.py"))
247 assert result is None
248
249 def test_ambiguous_raises_system_exit(self) -> None:
250 from muse.cli.commands.symbols import _resolve_file_filter
251 with pytest.raises(SystemExit):
252 _resolve_file_filter(
253 "billing.py",
254 self._manifest("a/billing.py", "b/billing.py"),
255 )
256
257
258 # ──────────────────────────────────────────────────────────────────────────────
259 # Unit — _lang_counts
260 # ──────────────────────────────────────────────────────────────────────────────
261
262
263 class TestLangCounts:
264 def _tree(self, n: int) -> Mapping[str, object]:
265 """Fake SymbolTree with n symbols."""
266 return {f"sym_{i}": {"lineno": i} for i in range(n)}
267
268 def test_single_python_file(self) -> None:
269 from muse.cli.commands.symbols import _lang_counts
270 counts = _lang_counts({"billing.py": self._tree(3)})
271 assert counts.get("Python") == 3
272
273 def test_multiple_files_summed_per_language(self) -> None:
274 from muse.cli.commands.symbols import _lang_counts
275 counts = _lang_counts({
276 "a.py": self._tree(2),
277 "b.py": self._tree(4),
278 })
279 assert counts.get("Python") == 6
280
281 def test_empty_map_returns_empty(self) -> None:
282 from muse.cli.commands.symbols import _lang_counts
283 assert _lang_counts({}) == {}
284
285 def test_unknown_extension_grouped_correctly(self) -> None:
286 from muse.cli.commands.symbols import _lang_counts
287 counts = _lang_counts({"thing.xyz": self._tree(1)})
288 assert sum(counts.values()) == 1
289
290
291 # ──────────────────────────────────────────────────────────────────────────────
292 # Unit — _print_human
293 # ──────────────────────────────────────────────────────────────────────────────
294
295
296 class TestPrintHuman:
297 def test_empty_map_prints_no_symbols(self, capsys: pytest.CaptureFixture[str]) -> None:
298 from muse.cli.commands.symbols import _print_human
299 _print_human({}, show_hashes=False, tty=False)
300 captured = capsys.readouterr()
301 assert "no semantic symbols found" in captured.out
302
303 def test_non_empty_map_shows_file_and_symbol(self, capsys: pytest.CaptureFixture[str]) -> None:
304 from muse.cli.commands.symbols import _print_human
305 tree = {
306 "billing.py::Invoice": {
307 "kind": "class",
308 "name": "Invoice",
309 "qualified_name": "Invoice",
310 "lineno": 1,
311 "content_id": fake_id("ab"),
312 }
313 }
314 _print_human({"billing.py": tree}, show_hashes=False, tty=False)
315 out = capsys.readouterr().out
316 assert "billing.py" in out
317 assert "Invoice" in out
318
319 def test_show_hashes_appends_hash_suffix(self, capsys: pytest.CaptureFixture[str]) -> None:
320 from muse.cli.commands.symbols import _print_human
321 tree = {
322 "f.py::fn": {
323 "kind": "function",
324 "name": "fn",
325 "qualified_name": "fn",
326 "lineno": 1,
327 "content_id": fake_id("cd"),
328 }
329 }
330 _print_human({"f.py": tree}, show_hashes=True, tty=False)
331 out = capsys.readouterr().out
332 assert ".." in out
333
334 def test_summary_line_shows_count(self, capsys: pytest.CaptureFixture[str]) -> None:
335 from muse.cli.commands.symbols import _print_human
336 tree = {
337 "f.py::fn": {
338 "kind": "function", "name": "fn", "qualified_name": "fn",
339 "lineno": 1, "content_id": fake_id("aa"),
340 }
341 }
342 _print_human({"f.py": tree}, show_hashes=False, tty=False)
343 out = capsys.readouterr().out
344 assert "1 symbol" in out or "symbol" in out
345
346
347 # ──────────────────────────────────────────────────────────────────────────────
348 # Unit — _emit_json
349 # ──────────────────────────────────────────────────────────────────────────────
350
351
352 class TestEmitJson:
353 def _tree(self) -> Mapping[str, object]:
354 return {
355 "billing.py::Invoice": {
356 "kind": "class",
357 "name": "Invoice",
358 "qualified_name": "Invoice",
359 "lineno": 1,
360 "end_lineno": 10,
361 "content_id": fake_id("aa"),
362 "body_hash": fake_id("bb"),
363 "signature_id": fake_id("cc"),
364 }
365 }
366
367 def test_emits_json_with_schema_version(self, capsys: pytest.CaptureFixture[str]) -> None:
368 from muse.cli.commands.symbols import _emit_json
369 _emit_json({"billing.py": self._tree()}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
370 d = json.loads(capsys.readouterr().out)
371 assert "schema" in d
372
373 def test_emits_json_with_exit_code(self, capsys: pytest.CaptureFixture[str]) -> None:
374 from muse.cli.commands.symbols import _emit_json
375 _emit_json({"billing.py": self._tree()}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
376 d = json.loads(capsys.readouterr().out)
377 assert d["exit_code"] == 0
378
379 def test_emits_json_with_duration_ms(self, capsys: pytest.CaptureFixture[str]) -> None:
380 from muse.cli.commands.symbols import _emit_json
381 _emit_json({"billing.py": self._tree()}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
382 d = json.loads(capsys.readouterr().out)
383 assert "duration_ms" in d
384 assert isinstance(d["duration_ms"], float)
385
386 def test_total_symbols_correct(self, capsys: pytest.CaptureFixture[str]) -> None:
387 from muse.cli.commands.symbols import _emit_json
388 _emit_json({"billing.py": self._tree()}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
389 d = json.loads(capsys.readouterr().out)
390 assert d["total_symbols"] == 1
391
392 def test_result_entry_has_path_field(self, capsys: pytest.CaptureFixture[str]) -> None:
393 from muse.cli.commands.symbols import _emit_json
394 _emit_json({"billing.py": self._tree()}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
395 d = json.loads(capsys.readouterr().out)
396 assert d["results"][0]["path"] == "billing.py"
397
398 def test_result_entry_fields_complete(self, capsys: pytest.CaptureFixture[str]) -> None:
399 from muse.cli.commands.symbols import _emit_json
400 _emit_json({"billing.py": self._tree()}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
401 d = json.loads(capsys.readouterr().out)
402 entry = d["results"][0]
403 for field in ("address", "kind", "name", "qualified_name", "path",
404 "lineno", "end_lineno", "content_id", "body_hash", "signature_id"):
405 assert field in entry, f"missing field: {field}"
406
407 def test_working_tree_false_propagated(self, capsys: pytest.CaptureFixture[str]) -> None:
408 from muse.cli.commands.symbols import _emit_json
409 _emit_json({"billing.py": self._tree()}, source_ref="a1b2c3d4", working_tree=False, elapsed=lambda: 0.0)
410 d = json.loads(capsys.readouterr().out)
411 assert d["working_tree"] is False
412
413 def test_empty_map_emits_zero_results(self, capsys: pytest.CaptureFixture[str]) -> None:
414 from muse.cli.commands.symbols import _emit_json
415 _emit_json({}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
416 d = json.loads(capsys.readouterr().out)
417 assert d["total_symbols"] == 0
418 assert d["results"] == []
419
420
421 # ──────────────────────────────────────────────────────────────────────────────
422 # Integration — alias, docstrings, envelope
423 # ──────────────────────────────────────────────────────────────────────────────
424
425
426 class TestAliasRegistration:
427 def test_j_alias_registered(self) -> None:
428 from muse.cli.commands.symbols import register
429 import argparse
430 p = argparse.ArgumentParser()
431 sub = p.add_subparsers()
432 register(sub)
433 ns = p.parse_args(["symbols", "-j"])
434 assert ns.json_out is True
435
436 def test_json_long_form_works(self) -> None:
437 from muse.cli.commands.symbols import register
438 import argparse
439 p = argparse.ArgumentParser()
440 sub = p.add_subparsers()
441 register(sub)
442 ns = p.parse_args(["symbols", "--json"])
443 assert ns.json_out is True
444
445
446 class TestDocstrings:
447 def test_register_mentions_json_alias(self) -> None:
448 from muse.cli.commands.symbols import register
449 doc = register.__doc__ or ""
450 assert "--json" in doc or "-j" in doc
451
452 def test_run_mentions_exit_code(self) -> None:
453 from muse.cli.commands.symbols import run
454 assert "exit_code" in (run.__doc__ or "")
455
456 def test_run_mentions_duration_ms(self) -> None:
457 from muse.cli.commands.symbols import run
458 assert "duration_ms" in (run.__doc__ or "")
459
460 def test_run_mentions_schema_version(self) -> None:
461 from muse.cli.commands.symbols import run
462 assert "schema" in (run.__doc__ or "")
463
464
465 class TestJsonEnvelope:
466 def test_schema_version_present(self, sym_repo: pathlib.Path) -> None:
467 r = _syms(sym_repo, "--json")
468 assert r.exit_code == 0
469 assert "schema" in json.loads(r.output)
470
471 def test_exit_code_zero(self, sym_repo: pathlib.Path) -> None:
472 r = _syms(sym_repo, "--json")
473 assert r.exit_code == 0
474 assert json.loads(r.output)["exit_code"] == 0
475
476 def test_duration_ms_is_float(self, sym_repo: pathlib.Path) -> None:
477 r = _syms(sym_repo, "--json")
478 assert r.exit_code == 0
479 d = json.loads(r.output)
480 assert isinstance(d["duration_ms"], float)
481
482 def test_schema_version_nonempty_string(self, sym_repo: pathlib.Path) -> None:
483 r = _syms(sym_repo, "--json")
484 assert r.exit_code == 0
485 d = json.loads(r.output)
486 assert isinstance(d["schema"], int) and d["schema"] > 0
487
488
489 class TestJsonAlias:
490 def test_j_parity_with_json(self, sym_repo: pathlib.Path) -> None:
491 r1 = _syms(sym_repo, "--json")
492 r2 = _syms(sym_repo, "-j")
493 assert r1.exit_code == 0
494 assert r2.exit_code == 0
495 d1, d2 = json.loads(r1.output), json.loads(r2.output)
496 assert d1["total_symbols"] == d2["total_symbols"]
497 assert d1["results"] == d2["results"]
498 assert d1["schema"] == d2["schema"]
499 assert d1["exit_code"] == d2["exit_code"]
500
501
502 # ──────────────────────────────────────────────────────────────────────────────
503 # End-to-end
504 # ──────────────────────────────────────────────────────────────────────────────
505
506
507 class TestEndToEnd:
508 def test_basic_exits_zero(self, sym_repo: pathlib.Path) -> None:
509 assert _syms(sym_repo).exit_code == 0
510
511 def test_basic_shows_symbols(self, sym_repo: pathlib.Path) -> None:
512 r = _syms(sym_repo)
513 assert "Invoice" in r.output
514 assert "symbols across" in r.output
515
516 def test_count_flag(self, sym_repo: pathlib.Path) -> None:
517 r = _syms(sym_repo, "--count")
518 assert r.exit_code == 0
519 assert "symbols" in r.output
520 assert "Python" in r.output
521 assert "Invoice" not in r.output
522
523 def test_kind_class_filter(self, sym_repo: pathlib.Path) -> None:
524 r = _syms(sym_repo, "--kind", "class")
525 assert r.exit_code == 0
526 assert "Invoice" in r.output
527 assert "process_order" not in r.output
528
529 def test_kind_function_filter(self, sym_repo: pathlib.Path) -> None:
530 r = _syms(sym_repo, "--kind", "function")
531 assert r.exit_code == 0
532 assert "process_order" in r.output
533 assert "Invoice" not in r.output
534
535 def test_invalid_kind_exits_nonzero(self, sym_repo: pathlib.Path) -> None:
536 r = _syms(sym_repo, "--kind", "potato")
537 assert r.exit_code != 0
538
539 def test_file_filter(self, sym_repo: pathlib.Path) -> None:
540 r = _syms(sym_repo, "--file", "billing.py")
541 assert r.exit_code == 0
542 assert "Invoice" in r.output
543
544 def test_file_filter_no_match_shows_no_symbols(self, sym_repo: pathlib.Path) -> None:
545 r = _syms(sym_repo, "--file", "nonexistent.py")
546 assert r.exit_code == 0
547 assert "no semantic symbols found" in r.output
548
549 def test_language_filter_python(self, sym_repo: pathlib.Path) -> None:
550 r = _syms(sym_repo, "--language", "python")
551 assert r.exit_code == 0
552 assert "Invoice" in r.output
553
554 def test_language_filter_case_insensitive(self, sym_repo: pathlib.Path) -> None:
555 for variant in ("python", "Python", "PYTHON"):
556 r = _syms(sym_repo, "--language", variant)
557 assert r.exit_code == 0, f"failed for {variant!r}"
558 assert "Invoice" in r.output
559
560 def test_language_no_match_shows_no_symbols(self, sym_repo: pathlib.Path) -> None:
561 r = _syms(sym_repo, "--language", "Go")
562 assert r.exit_code == 0
563 assert "no semantic symbols found" in r.output
564
565 def test_hashes_flag(self, sym_repo: pathlib.Path) -> None:
566 r = _syms(sym_repo, "--hashes")
567 assert r.exit_code == 0
568 assert ".." in r.output
569
570 def test_count_and_json_mutually_exclusive(self, sym_repo: pathlib.Path) -> None:
571 r = _syms(sym_repo, "--count", "--json")
572 assert r.exit_code != 0
573
574 def test_commit_head_exits_zero(self, sym_repo: pathlib.Path) -> None:
575 r = _syms(sym_repo, "--commit", "HEAD")
576 assert r.exit_code == 0
577 assert "Invoice" in r.output
578
579 def test_json_working_tree_true_when_no_commit(self, sym_repo: pathlib.Path) -> None:
580 r = _syms(sym_repo, "--json")
581 assert r.exit_code == 0
582 d = json.loads(r.output)
583 assert d["working_tree"] is True
584 assert d["source_ref"] == "working-tree"
585
586 def test_json_working_tree_false_with_commit(self, sym_repo: pathlib.Path) -> None:
587 r = _syms(sym_repo, "--json", "--commit", "HEAD")
588 assert r.exit_code == 0
589 d = json.loads(r.output)
590 assert d["working_tree"] is False
591 assert d["source_ref"] != "working-tree"
592
593 def test_json_result_path_field(self, sym_repo: pathlib.Path) -> None:
594 r = _syms(sym_repo, "--json", "--file", "billing.py")
595 assert r.exit_code == 0
596 d = json.loads(r.output)
597 assert all(e["path"] == "billing.py" for e in d["results"])
598
599 def test_j_alias_works_in_cli(self, sym_repo: pathlib.Path) -> None:
600 r = _syms(sym_repo, "-j")
601 assert r.exit_code == 0
602 d = json.loads(r.output)
603 assert "total_symbols" in d
604
605 def test_invalid_commit_ref_exits_nonzero(self, sym_repo: pathlib.Path) -> None:
606 r = _syms(sym_repo, "--commit", "deadbeefdeadbeef")
607 assert r.exit_code != 0
608
609
610 # ──────────────────────────────────────────────────────────────────────────────
611 # Stress
612 # ──────────────────────────────────────────────────────────────────────────────
613
614
615 class TestStress:
616 def test_1000_file_matches_calls(self) -> None:
617 from muse.cli.commands.symbols import _file_matches
618 for i in range(1_000):
619 _file_matches(f"src/file{i}.py", "billing.py")
620
621 def test_10000_normalise_language_calls(self) -> None:
622 from muse.cli.commands.symbols import _normalise_language
623 for _ in range(10_000):
624 result = _normalise_language("python")
625 assert result == "Python"
626
627 def test_emit_json_500_symbol_map(self, capsys: pytest.CaptureFixture[str]) -> None:
628 from muse.cli.commands.symbols import _emit_json
629 tree = {
630 f"f.py::sym_{i}": {
631 "kind": "function",
632 "name": f"sym_{i}",
633 "qualified_name": f"sym_{i}",
634 "lineno": i + 1,
635 "end_lineno": i + 5,
636 "content_id": fake_id("aa"),
637 "body_hash": fake_id("bb"),
638 "signature_id": fake_id("cc"),
639 }
640 for i in range(500)
641 }
642 _emit_json({"f.py": tree}, source_ref="abc123", working_tree=True, elapsed=lambda: 0.0)
643 d = json.loads(capsys.readouterr().out)
644 assert d["total_symbols"] == 500
645
646 def test_concurrent_file_matches(self) -> None:
647 from muse.cli.commands.symbols import _file_matches
648 results: list[bool] = []
649 lock = threading.Lock()
650
651 def _run() -> None:
652 v = _file_matches("src/billing.py", "billing.py")
653 with lock:
654 results.append(v)
655
656 threads = [threading.Thread(target=_run) for _ in range(50)]
657 for t in threads: t.start()
658 for t in threads: t.join()
659 assert all(results)
660 assert len(results) == 50
661
662
663 # ──────────────────────────────────────────────────────────────────────────────
664 # Data integrity
665 # ──────────────────────────────────────────────────────────────────────────────
666
667
668 class TestDataIntegrity:
669 def test_total_symbols_matches_results_length(self, sym_repo: pathlib.Path) -> None:
670 r = _syms(sym_repo, "--json")
671 assert r.exit_code == 0
672 d = json.loads(r.output)
673 assert d["total_symbols"] == len(d["results"])
674
675 def test_json_results_ordered_by_lineno(self, sym_repo: pathlib.Path) -> None:
676 r = _syms(sym_repo, "--json", "--file", "billing.py")
677 assert r.exit_code == 0
678 linenos = [e["lineno"] for e in json.loads(r.output)["results"]]
679 assert linenos == sorted(linenos)
680
681 def test_all_result_fields_present(self, sym_repo: pathlib.Path) -> None:
682 r = _syms(sym_repo, "--json")
683 assert r.exit_code == 0
684 for entry in json.loads(r.output)["results"]:
685 for field in ("address", "kind", "name", "qualified_name", "path",
686 "lineno", "end_lineno", "content_id", "body_hash", "signature_id"):
687 assert field in entry, f"missing field: {field}"
688
689 def test_schema_version_is_string(self, sym_repo: pathlib.Path) -> None:
690 r = _syms(sym_repo, "--json")
691 assert r.exit_code == 0
692 assert isinstance(json.loads(r.output)["schema"], int)
693
694 def test_exit_code_is_int(self, sym_repo: pathlib.Path) -> None:
695 r = _syms(sym_repo, "--json")
696 assert r.exit_code == 0
697 assert isinstance(json.loads(r.output)["exit_code"], int)
698
699 def test_duration_ms_nonnegative(self, sym_repo: pathlib.Path) -> None:
700 r = _syms(sym_repo, "--json")
701 assert r.exit_code == 0
702 assert json.loads(r.output)["duration_ms"] >= 0
703
704 def test_working_tree_is_bool(self, sym_repo: pathlib.Path) -> None:
705 r = _syms(sym_repo, "--json")
706 assert r.exit_code == 0
707 assert isinstance(json.loads(r.output)["working_tree"], bool)
708
709 def test_kind_filter_propagated_to_results(self, sym_repo: pathlib.Path) -> None:
710 r = _syms(sym_repo, "--json", "--kind", "class")
711 assert r.exit_code == 0
712 d = json.loads(r.output)
713 assert all(e["kind"] == "class" for e in d["results"])
714
715 def test_file_filter_propagated_to_results(self, sym_repo: pathlib.Path) -> None:
716 r = _syms(sym_repo, "--json", "--file", "billing.py")
717 assert r.exit_code == 0
718 d = json.loads(r.output)
719 assert all(e["path"] == "billing.py" for e in d["results"])
720
721 def test_lang_counts_total_matches_total_symbols(self, sym_repo: pathlib.Path) -> None:
722 from muse.cli.commands.symbols import _lang_counts
723 tree = {
724 "a.py::f1": {"lineno": 1},
725 "a.py::f2": {"lineno": 2},
726 }
727 counts = _lang_counts({"a.py": tree})
728 assert sum(counts.values()) == 2
729
730
731 # ──────────────────────────────────────────────────────────────────────────────
732 # Security
733 # ──────────────────────────────────────────────────────────────────────────────
734
735
736 class TestSecurity:
737 def test_ansi_in_file_filter_does_not_crash(self, sym_repo: pathlib.Path) -> None:
738 r = _syms(sym_repo, "--file", "\x1b[31mbad\x1b[0m.py")
739 assert r.exit_code in (0, 1, 2)
740
741 def test_very_long_language_does_not_crash(self, sym_repo: pathlib.Path) -> None:
742 r = _syms(sym_repo, "--language", "x" * 10_000)
743 assert r.exit_code in (0, 1, 2)
744
745 def test_sql_injection_in_file_filter_does_not_crash(self, sym_repo: pathlib.Path) -> None:
746 r = _syms(sym_repo, "--file", "'; DROP TABLE symbols; --")
747 assert r.exit_code in (0, 1, 2)
748
749 def test_file_matches_with_ansi_in_path(self) -> None:
750 from muse.cli.commands.symbols import _file_matches
751 malicious = "\x1b[31mbilling\x1b[0m.py"
752 # Should return False without raising
753 result = _file_matches("billing.py", malicious)
754 assert isinstance(result, bool)
755
756 def test_hostile_detail_survives_json_serialisation(self, capsys: pytest.CaptureFixture[str]) -> None:
757 from muse.cli.commands.symbols import _emit_json
758 tree = {
759 "f.py::fn": {
760 "kind": "function",
761 "name": '"; DROP TABLE --',
762 "qualified_name": '"; DROP TABLE --',
763 "lineno": 1,
764 "end_lineno": 5,
765 "content_id": fake_id("aa"),
766 "body_hash": fake_id("bb"),
767 "signature_id": fake_id("cc"),
768 }
769 }
770 _emit_json({"f.py": tree}, source_ref="abc", working_tree=True, elapsed=lambda: 0.0)
771 d = json.loads(capsys.readouterr().out)
772 assert d["results"][0]["name"] == '"; DROP TABLE --'
773
774 def test_unicode_in_file_filter_does_not_crash(self, sym_repo: pathlib.Path) -> None:
775 r = _syms(sym_repo, "--file", "音符.py")
776 assert r.exit_code in (0, 1, 2)
777
778
779 # ──────────────────────────────────────────────────────────────────────────────
780 # Performance
781 # ──────────────────────────────────────────────────────────────────────────────
782
783
784 class TestPerformance:
785 def test_10000_file_matches_under_500ms(self) -> None:
786 from muse.cli.commands.symbols import _file_matches
787 start = time.perf_counter()
788 for i in range(10_000):
789 _file_matches(f"src/billing_{i}.py", "billing.py")
790 elapsed = time.perf_counter() - start
791 assert elapsed < 0.5, f"10 000 _file_matches took {elapsed:.2f}s"
792
793 def test_duration_ms_under_30000ms(self, sym_repo: pathlib.Path) -> None:
794 r = _syms(sym_repo, "--json")
795 assert r.exit_code == 0
796 d = json.loads(r.output)
797 assert d["duration_ms"] < 30_000
798
799
800 # Flag registration
801 # ──────────────────────────────────────────────────────────────────────────────
802
803
804 class TestRegisterFlags:
805 def _parse(self, *args: str) -> "argparse.Namespace":
806 import argparse
807 from muse.cli.commands.symbols import register
808 p = argparse.ArgumentParser()
809 sub = p.add_subparsers()
810 register(sub)
811 return p.parse_args(["symbols", *args])
812
813 def test_default_json_out_is_false(self) -> None:
814 ns = self._parse()
815 assert ns.json_out is False
816
817 def test_json_flag_sets_json_out(self) -> None:
818 ns = self._parse("--json")
819 assert ns.json_out is True
820
821 def test_j_shorthand_sets_json_out(self) -> None:
822 ns = self._parse("-j")
823 assert ns.json_out is True
File History 1 commit