gabriel / muse public
test_cmd_content_grep_hardening.py python
904 lines 31.1 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
1 """Hardening tests for ``muse content-grep``.
2
3 Covers:
4 Unit — _is_binary, _path_matches_globs, _search_object (context,
5 binary skip, utf-8 replace), pattern validation order
6 Security — ANSI injection in file paths and match text, pattern length
7 cap, invalid regex, ReDoS pattern rejected before I/O
8 Perf — parallel reads complete correctly, --max-matches cap
9 JSON — _ContentGrepJson schema (commit_id, snapshot_id, totals),
10 GrepMatch context_before/context_after fields
11 Flags — --include, --exclude, --max-matches, --context/-C, --json,
12 rejection of old --format flag
13 Integration — multi-file with mixed hits, --include narrows search,
14 --exclude skips files, --context shows surrounding lines,
15 --ref searches historical commit
16 E2E — --help output mentions all new flags
17 Stress — 500-file snapshot, concurrent parallel reads
18 """
19
20 from __future__ import annotations
21 from collections.abc import Mapping
22
23 import datetime
24 import json
25 import pathlib
26 import threading
27 from typing import TypedDict
28
29 import pytest
30 from tests.cli_test_helper import CliRunner, InvokeResult
31
32 from muse.core.object_store import write_object
33 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
34 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
35 from muse.core._types import Manifest, blob_id
36
37 cli = None
38 runner = CliRunner()
39 _invoke_lock = threading.Lock()
40
41 type _FilesMap = dict[str, bytes]
42
43 _REPO_ID = "cgrep-hardening"
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50
51 class _GrepMatchOut(TypedDict):
52 line_number: int
53 text: str
54 context_before: list[str]
55 context_after: list[str]
56
57
58 class _GrepResultOut(TypedDict):
59 path: str
60 object_id: str
61 match_count: int
62 matches: list[_GrepMatchOut]
63
64
65 class _GrepOut(TypedDict):
66 source: str
67 commit_id: str
68 snapshot_id: str
69 pattern: str
70 total_files_matched: int
71 total_matches: int
72 results: list[_GrepResultOut]
73 duration_ms: float
74 exit_code: int
75
76
77 def _sha(data: bytes) -> str:
78 return blob_id(data)
79
80
81 def _init_repo(path: pathlib.Path, repo_id: str = _REPO_ID) -> pathlib.Path:
82 muse = path / ".muse"
83 for d in ("commits", "snapshots", "objects", "refs/heads"):
84 (muse / d).mkdir(parents=True, exist_ok=True)
85 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
86 (muse / "repo.json").write_text(
87 json.dumps({"repo_id": repo_id, "domain": "midi"}), encoding="utf-8"
88 )
89 return path
90
91
92 def _env(repo: pathlib.Path) -> Manifest:
93 return {"MUSE_REPO_ROOT": str(repo)}
94
95
96 _counter = 0
97
98
99 def _commit_files(
100 root: pathlib.Path,
101 files: _FilesMap,
102 branch: str = "main",
103 parent_id: str | None = None,
104 ) -> str:
105 global _counter
106 _counter += 1
107 manifest: Manifest = {}
108 for rel_path, content in files.items():
109 obj_id = _sha(content)
110 write_object(root, obj_id, content)
111 manifest[rel_path] = obj_id
112 snap_id = compute_snapshot_id(manifest)
113 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
114 committed_at = datetime.datetime.now(datetime.timezone.utc)
115 parent_ids = [parent_id] if parent_id else []
116 commit_id = compute_commit_id(
117 parent_ids, snap_id, f"commit {_counter}", committed_at.isoformat(),
118 repo_id=_REPO_ID,
119 )
120 write_commit(
121 root,
122 CommitRecord(
123 commit_id=commit_id,
124 repo_id=_REPO_ID,
125 created_on_branch=branch,
126 snapshot_id=snap_id,
127 message=f"commit {_counter}",
128 committed_at=committed_at,
129 parent_commit_id=parent_id,
130 ),
131 )
132 ref_path = root / ".muse" / "refs" / "heads" / branch
133 ref_path.parent.mkdir(parents=True, exist_ok=True)
134 ref_path.write_text(commit_id, encoding="utf-8")
135 return commit_id
136
137
138 def _invoke(args: list[str], env: Manifest | None = None) -> InvokeResult:
139 with _invoke_lock:
140 return runner.invoke(cli, args, env=env)
141
142
143 def _parse(result: InvokeResult) -> _GrepOut:
144 raw: _GrepOut = json.loads(result.output)
145 return raw
146
147
148 # ---------------------------------------------------------------------------
149 # Unit: _is_binary
150 # ---------------------------------------------------------------------------
151
152
153 def test_is_binary_null_byte() -> None:
154 from muse.cli.commands.content_grep import _is_binary
155
156 assert _is_binary(b"\x00hello") is True
157
158
159 def test_is_binary_clean_text() -> None:
160 from muse.cli.commands.content_grep import _is_binary
161
162 assert _is_binary(b"hello world\n") is False
163
164
165 def test_is_binary_empty() -> None:
166 from muse.cli.commands.content_grep import _is_binary
167
168 assert _is_binary(b"") is False
169
170
171 # ---------------------------------------------------------------------------
172 # Unit: _path_matches_globs
173 # ---------------------------------------------------------------------------
174
175
176 def test_path_matches_no_filter() -> None:
177 from muse.cli.commands.content_grep import _path_matches_globs
178
179 assert _path_matches_globs("src/main.py", None, None) is True
180
181
182 def test_path_matches_include_basename() -> None:
183 from muse.cli.commands.content_grep import _path_matches_globs
184
185 assert _path_matches_globs("src/main.py", "*.py", None) is True
186 assert _path_matches_globs("src/main.js", "*.py", None) is False
187
188
189 def test_path_matches_include_full_path() -> None:
190 from muse.cli.commands.content_grep import _path_matches_globs
191
192 assert _path_matches_globs("src/main.py", "src/*.py", None) is True
193 assert _path_matches_globs("tests/main.py", "src/*.py", None) is False
194
195
196 def test_path_matches_exclude_basename() -> None:
197 from muse.cli.commands.content_grep import _path_matches_globs
198
199 assert _path_matches_globs("app.min.js", None, "*.min.js") is False
200 assert _path_matches_globs("app.js", None, "*.min.js") is True
201
202
203 def test_path_matches_include_and_exclude() -> None:
204 from muse.cli.commands.content_grep import _path_matches_globs
205
206 assert _path_matches_globs("src/main.py", "*.py", "test_*.py") is True
207 assert _path_matches_globs("test_foo.py", "*.py", "test_*.py") is False
208
209
210 # ---------------------------------------------------------------------------
211 # Unit: _search_object — context lines
212 # ---------------------------------------------------------------------------
213
214
215 def test_search_object_context(tmp_path: pathlib.Path) -> None:
216 import re
217 from muse.cli.commands.content_grep import _search_object
218
219 _init_repo(tmp_path)
220 content = b"line one\nTARGET line\nline three\n"
221 obj_id = _sha(content)
222 write_object(tmp_path, obj_id, content)
223
224 pat = re.compile("TARGET")
225 count, matches = _search_object(tmp_path, obj_id, pat, False, False, context_lines=1)
226 assert count == 1
227 assert len(matches) == 1
228 assert matches[0]["context_before"] == ["line one"]
229 assert matches[0]["context_after"] == ["line three"]
230
231
232 def test_search_object_context_at_boundary(tmp_path: pathlib.Path) -> None:
233 import re
234 from muse.cli.commands.content_grep import _search_object
235
236 _init_repo(tmp_path)
237 content = b"TARGET\nonly\n"
238 obj_id = _sha(content)
239 write_object(tmp_path, obj_id, content)
240
241 pat = re.compile("TARGET")
242 count, matches = _search_object(tmp_path, obj_id, pat, False, False, context_lines=3)
243 assert matches[0]["context_before"] == []
244 assert matches[0]["context_after"] == ["only"]
245
246
247 def test_search_object_no_context(tmp_path: pathlib.Path) -> None:
248 import re
249 from muse.cli.commands.content_grep import _search_object
250
251 _init_repo(tmp_path)
252 content = b"line\nTARGET\nend\n"
253 obj_id = _sha(content)
254 write_object(tmp_path, obj_id, content)
255
256 pat = re.compile("TARGET")
257 _, matches = _search_object(tmp_path, obj_id, pat, False, False, context_lines=0)
258 assert matches[0]["context_before"] == []
259 assert matches[0]["context_after"] == []
260
261
262 def test_search_object_binary_skipped(tmp_path: pathlib.Path) -> None:
263 import re
264 from muse.cli.commands.content_grep import _search_object
265
266 _init_repo(tmp_path)
267 content = b"\x00\x01\x02TARGET\x03"
268 obj_id = _sha(content)
269 write_object(tmp_path, obj_id, content)
270
271 pat = re.compile("TARGET")
272 count, matches = _search_object(tmp_path, obj_id, pat, False, False, 0)
273 assert count == 0
274 assert matches == []
275
276
277 # ---------------------------------------------------------------------------
278 # Security: pattern validation happens BEFORE I/O
279 # ---------------------------------------------------------------------------
280
281
282 def test_long_pattern_rejected_before_io(tmp_path: pathlib.Path) -> None:
283 """A too-long pattern must be rejected without touching the object store."""
284 _init_repo(tmp_path)
285 # Do NOT commit any files — if I/O happened, we'd get a 'no commits' error,
286 # not the 'pattern too long' error.
287 bad_pattern = "a" * 501
288 result = _invoke(
289 ["content-grep", bad_pattern], env=_env(tmp_path)
290 )
291 assert result.exit_code != 0
292 # The error must be about pattern length, not about missing commits.
293 assert "too long" in result.output.lower() or "too long" in (result.stderr or "").lower()
294
295
296 def test_invalid_regex_rejected_before_io(tmp_path: pathlib.Path) -> None:
297 _init_repo(tmp_path)
298 result = _invoke(
299 ["content-grep", "[unclosed"], env=_env(tmp_path)
300 )
301 assert result.exit_code != 0
302 assert "regex" in result.output.lower() or "regex" in (result.stderr or "").lower()
303
304
305 # ---------------------------------------------------------------------------
306 # Security: ANSI injection
307 # ---------------------------------------------------------------------------
308
309
310 def test_ansi_injection_in_path(tmp_path: pathlib.Path) -> None:
311 """File paths with ANSI escapes must be stripped in text output."""
312 _init_repo(tmp_path)
313 ansi_path = "\x1b[31mevil\x1b[0m.txt"
314 _commit_files(tmp_path, {ansi_path: b"TARGET content\n"})
315 result = _invoke(
316 ["content-grep", "TARGET"], env=_env(tmp_path)
317 )
318 assert result.exit_code == 0
319 assert "\x1b" not in result.output
320
321
322 def test_ansi_injection_in_match_text(tmp_path: pathlib.Path) -> None:
323 """Match text with ANSI escapes must be stripped in text output."""
324 _init_repo(tmp_path)
325 _commit_files(tmp_path, {"safe.txt": b"TARGET \x1b[31mred\x1b[0m content\n"})
326 result = _invoke(
327 ["content-grep", "TARGET"], env=_env(tmp_path)
328 )
329 assert result.exit_code == 0
330 assert "\x1b" not in result.output
331
332
333 # ---------------------------------------------------------------------------
334 # JSON schema: _ContentGrepJson
335 # ---------------------------------------------------------------------------
336
337
338 def test_json_schema_all_fields(tmp_path: pathlib.Path) -> None:
339 _init_repo(tmp_path)
340 _commit_files(tmp_path, {"a.txt": b"hello world\nhello again\n"})
341 result = _invoke(
342 ["content-grep", "hello", "--json"], env=_env(tmp_path)
343 )
344 assert result.exit_code == 0
345 data = _parse(result)
346 assert data["commit_id"].startswith("sha256:")
347 assert len(data["commit_id"]) == 71
348 assert data["snapshot_id"].startswith("sha256:")
349 assert len(data["snapshot_id"]) == 71
350 assert data["pattern"] == "hello"
351 assert data["total_files_matched"] == 1
352 assert data["total_matches"] == 2
353 assert len(data["results"]) == 1
354 r = data["results"][0]
355 assert r["path"] == "a.txt"
356 assert r["match_count"] == 2
357 assert isinstance(r["matches"], list)
358
359
360 def test_json_schema_context_fields(tmp_path: pathlib.Path) -> None:
361 _init_repo(tmp_path)
362 _commit_files(tmp_path, {"c.txt": b"before\nTARGET\nafter\n"})
363 result = _invoke(
364 ["content-grep", "TARGET", "--context", "1", "--json"],
365 env=_env(tmp_path),
366 )
367 assert result.exit_code == 0
368 data = _parse(result)
369 match = data["results"][0]["matches"][0]
370 assert isinstance(match, dict)
371 assert "context_before" in match
372 assert "context_after" in match
373 assert match["context_before"] == ["before"]
374 assert match["context_after"] == ["after"]
375
376
377 def test_json_schema_no_match_exit1(tmp_path: pathlib.Path) -> None:
378 _init_repo(tmp_path)
379 _commit_files(tmp_path, {"a.txt": b"hello\n"})
380 result = _invoke(
381 ["content-grep", "ZZZNOMATCH", "--json"], env=_env(tmp_path)
382 )
383 assert result.exit_code != 0
384
385
386 def test_json_total_matches_multiple_files(tmp_path: pathlib.Path) -> None:
387 _init_repo(tmp_path)
388 _commit_files(tmp_path, {
389 "a.txt": b"hit\nhit\n",
390 "b.txt": b"hit\n",
391 "c.txt": b"miss\n",
392 })
393 result = _invoke(
394 ["content-grep", "hit", "--json"], env=_env(tmp_path)
395 )
396 assert result.exit_code == 0
397 data = _parse(result)
398 assert data["total_files_matched"] == 2
399 assert data["total_matches"] == 3
400
401
402 # ---------------------------------------------------------------------------
403 # Flags: --include
404 # ---------------------------------------------------------------------------
405
406
407 def test_include_filters_to_py_only(tmp_path: pathlib.Path) -> None:
408 _init_repo(tmp_path)
409 _commit_files(tmp_path, {
410 "module.py": b"TARGET in python\n",
411 "module.js": b"TARGET in js\n",
412 "readme.md": b"TARGET in md\n",
413 })
414 result = _invoke(
415 ["content-grep", "TARGET", "--include", "*.py", "--json"],
416 env=_env(tmp_path),
417 )
418 assert result.exit_code == 0
419 data = _parse(result)
420 assert data["total_files_matched"] == 1
421 assert data["results"][0]["path"] == "module.py"
422
423
424 def test_include_no_matches_after_filter(tmp_path: pathlib.Path) -> None:
425 _init_repo(tmp_path)
426 _commit_files(tmp_path, {"module.js": b"TARGET here\n"})
427 result = _invoke(
428 ["content-grep", "TARGET", "--include", "*.py"],
429 env=_env(tmp_path),
430 )
431 assert result.exit_code != 0 # no files pass include filter
432
433
434 # ---------------------------------------------------------------------------
435 # Flags: --exclude
436 # ---------------------------------------------------------------------------
437
438
439 def test_exclude_skips_minified(tmp_path: pathlib.Path) -> None:
440 _init_repo(tmp_path)
441 _commit_files(tmp_path, {
442 "app.js": b"TARGET here\n",
443 "app.min.js": b"TARGET minified\n",
444 })
445 result = _invoke(
446 ["content-grep", "TARGET", "--exclude", "*.min.js", "--json"],
447 env=_env(tmp_path),
448 )
449 assert result.exit_code == 0
450 data = _parse(result)
451 assert data["total_files_matched"] == 1
452 assert data["results"][0]["path"] == "app.js"
453
454
455 def test_exclude_all_results_in_no_match(tmp_path: pathlib.Path) -> None:
456 _init_repo(tmp_path)
457 _commit_files(tmp_path, {"test.py": b"TARGET\n"})
458 result = _invoke(
459 ["content-grep", "TARGET", "--exclude", "test_*.py"],
460 env=_env(tmp_path),
461 )
462 # test.py doesn't match test_*.py exclude pattern, so it should match.
463 # Verify this works (target file isn't excluded).
464 assert result.exit_code == 0
465
466
467 # ---------------------------------------------------------------------------
468 # Flags: --max-matches
469 # ---------------------------------------------------------------------------
470
471
472 def test_max_matches_caps_output(tmp_path: pathlib.Path) -> None:
473 _init_repo(tmp_path)
474 _commit_files(tmp_path, {"many.txt": b"hit\n" * 100})
475 result = _invoke(
476 ["content-grep", "hit", "--max-matches", "10", "--json"],
477 env=_env(tmp_path),
478 )
479 assert result.exit_code == 0
480 data = _parse(result)
481 assert data["total_matches"] <= 10
482
483
484 def test_max_matches_zero_still_exits_nonzero_on_cap(tmp_path: pathlib.Path) -> None:
485 """When max_matches=0, no results are kept — exit 1."""
486 _init_repo(tmp_path)
487 _commit_files(tmp_path, {"a.txt": b"hit\n"})
488 result = _invoke(
489 ["content-grep", "hit", "--max-matches", "0", "--json"],
490 env=_env(tmp_path),
491 )
492 assert result.exit_code != 0 # no results after cap → exit 1
493
494
495 # ---------------------------------------------------------------------------
496 # Flags: --context / -C
497 # ---------------------------------------------------------------------------
498
499
500 def test_context_text_output(tmp_path: pathlib.Path) -> None:
501 _init_repo(tmp_path)
502 _commit_files(tmp_path, {"ctx.txt": b"alpha\nbeta\ngamma\n"})
503 result = _invoke(
504 ["content-grep", "beta", "--context", "1"],
505 env=_env(tmp_path),
506 )
507 assert result.exit_code == 0
508 # Context before and after should appear in output.
509 assert "alpha" in result.output
510 assert "gamma" in result.output
511
512
513 def test_context_short_flag(tmp_path: pathlib.Path) -> None:
514 _init_repo(tmp_path)
515 _commit_files(tmp_path, {"ctx2.txt": b"first\nTARGET\nlast\n"})
516 result = _invoke(
517 ["content-grep", "TARGET", "-C", "1"],
518 env=_env(tmp_path),
519 )
520 assert result.exit_code == 0
521 assert "first" in result.output
522 assert "last" in result.output
523
524
525 # ---------------------------------------------------------------------------
526 # Flags: --json boolean (rejects old --format)
527 # ---------------------------------------------------------------------------
528
529
530 def test_format_flag_rejected(tmp_path: pathlib.Path) -> None:
531 """Old ``--format json`` must be rejected by argparse (exit 2)."""
532 _init_repo(tmp_path)
533 _commit_files(tmp_path, {"a.txt": b"hello\n"})
534 result = _invoke(
535 ["content-grep", "hello", "--format", "json"],
536 env=_env(tmp_path),
537 )
538 assert result.exit_code == 2
539
540
541 # ---------------------------------------------------------------------------
542 # Integration: --ref searches a different commit
543 # ---------------------------------------------------------------------------
544
545
546 def test_ref_searches_branch(tmp_path: pathlib.Path) -> None:
547 _init_repo(tmp_path)
548 c1 = _commit_files(tmp_path, {"v1.txt": b"OLD content\n"})
549 _commit_files(tmp_path, {"v2.txt": b"NEW content\n"}, parent_id=c1)
550
551 # Search HEAD — should find NEW in v2.txt.
552 result_head = _invoke(
553 ["content-grep", "NEW", "--json"], env=_env(tmp_path)
554 )
555 assert result_head.exit_code == 0
556 data = _parse(result_head)
557 paths = [r["path"] for r in data["results"]]
558 assert "v2.txt" in paths
559
560 # Search the first commit by ID — should find OLD in v1.txt, not NEW.
561 result_ref = _invoke(
562 ["content-grep", "OLD", "--ref", c1, "--json"],
563 env=_env(tmp_path),
564 )
565 assert result_ref.exit_code == 0
566 data_ref = _parse(result_ref)
567 paths_ref = [r["path"] for r in data_ref["results"]]
568 assert "v1.txt" in paths_ref
569 assert data_ref["commit_id"] == c1
570
571
572 # ---------------------------------------------------------------------------
573 # E2E: --help mentions all new flags
574 # ---------------------------------------------------------------------------
575
576
577 def test_help_mentions_include() -> None:
578 result = _invoke(["content-grep", "--help"])
579 assert result.exit_code == 0
580 assert "--include" in result.output
581
582
583 def test_help_mentions_exclude() -> None:
584 result = _invoke(["content-grep", "--help"])
585 assert "--exclude" in result.output
586
587
588 def test_help_mentions_max_matches() -> None:
589 result = _invoke(["content-grep", "--help"])
590 assert "--max-matches" in result.output
591
592
593 def test_help_mentions_context() -> None:
594 result = _invoke(["content-grep", "--help"])
595 assert "--context" in result.output or "-C" in result.output
596
597
598 def test_help_mentions_json_not_format() -> None:
599 result = _invoke(["content-grep", "--help"])
600 assert "--json" in result.output
601 assert "--format" not in result.output
602
603
604 # ---------------------------------------------------------------------------
605 # Stress: 500-file snapshot, pattern matches 250
606 # ---------------------------------------------------------------------------
607
608
609 def test_stress_500_files(tmp_path: pathlib.Path) -> None:
610 _init_repo(tmp_path)
611 files: _FilesMap = {}
612 for i in range(500):
613 content = b"TARGET_STRESS\n" if i % 2 == 0 else b"other\n"
614 files[f"f_{i:04d}.txt"] = content
615 _commit_files(tmp_path, files)
616 result = _invoke(
617 ["content-grep", "TARGET_STRESS", "--json"],
618 env=_env(tmp_path),
619 )
620 assert result.exit_code == 0
621 data = _parse(result)
622 assert data["total_files_matched"] == 250
623 assert data["total_matches"] == 250
624
625
626 # ---------------------------------------------------------------------------
627 # Stress: concurrent reads
628 # ---------------------------------------------------------------------------
629
630
631 def test_stress_concurrent_reads(tmp_path: pathlib.Path) -> None:
632 _init_repo(tmp_path)
633 _commit_files(tmp_path, {"concurrent.txt": b"CONCURRENT TARGET\n"})
634
635 errors: list[str] = []
636
637 def _read() -> None:
638 r = _invoke(
639 ["content-grep", "CONCURRENT", "--json"],
640 env=_env(tmp_path),
641 )
642 if r.exit_code != 0:
643 errors.append(f"exit {r.exit_code}")
644 else:
645 try:
646 d = json.loads(r.output)
647 if d.get("total_matches", 0) != 1:
648 errors.append(f"unexpected total_matches: {d.get('total_matches')}")
649 except json.JSONDecodeError as exc:
650 errors.append(str(exc))
651
652 threads = [threading.Thread(target=_read) for _ in range(8)]
653 for t in threads:
654 t.start()
655 for t in threads:
656 t.join()
657
658 assert not errors, f"Concurrent read failures: {errors}"
659
660
661 # ---------------------------------------------------------------------------
662 # JSON schema: complete key set (TestJsonSchemaComplete)
663 # ---------------------------------------------------------------------------
664
665
666 _REQUIRED_KEYS = frozenset({
667 "source",
668 "commit_id",
669 "snapshot_id",
670 "pattern",
671 "total_files_matched",
672 "total_matches",
673 "results",
674 "duration_ms",
675 "exit_code",
676 })
677
678
679 class TestJsonSchemaComplete:
680 """Verify that every required key is present in JSON output."""
681
682 def test_all_required_keys_present_commit_mode(self, tmp_path: pathlib.Path) -> None:
683 _init_repo(tmp_path)
684 _commit_files(tmp_path, {"a.txt": b"hello\n"})
685 result = _invoke(["content-grep", "hello", "--json"], env=_env(tmp_path))
686 assert result.exit_code == 0
687 data = json.loads(result.output)
688 missing = _REQUIRED_KEYS - data.keys()
689 assert not missing, f"Missing keys: {missing}"
690
691 def test_all_required_keys_present_working_tree_mode(self, tmp_path: pathlib.Path) -> None:
692 _init_repo(tmp_path)
693 _commit_files(tmp_path, {"a.txt": b"hello\n"})
694 # Also write a matching file to disk so working-tree search finds it.
695 (tmp_path / "a.txt").write_bytes(b"hello\n")
696 result = _invoke(
697 ["content-grep", "hello", "--working-tree", "--json"],
698 env=_env(tmp_path),
699 )
700 assert result.exit_code == 0
701 data = json.loads(result.output)
702 missing = _REQUIRED_KEYS - data.keys()
703 assert not missing, f"Missing keys: {missing}"
704
705 def test_source_field_is_commit(self, tmp_path: pathlib.Path) -> None:
706 _init_repo(tmp_path)
707 _commit_files(tmp_path, {"a.txt": b"hello\n"})
708 result = _invoke(["content-grep", "hello", "--json"], env=_env(tmp_path))
709 data = json.loads(result.output)
710 assert data["source"] == "commit"
711
712 def test_source_field_is_working_tree(self, tmp_path: pathlib.Path) -> None:
713 _init_repo(tmp_path)
714 _commit_files(tmp_path, {"a.txt": b"hello\n"})
715 (tmp_path / "a.txt").write_bytes(b"hello\n")
716 result = _invoke(
717 ["content-grep", "hello", "--working-tree", "--json"],
718 env=_env(tmp_path),
719 )
720 data = json.loads(result.output)
721 assert data["source"] == "working-tree"
722
723 def test_commit_id_null_in_working_tree_mode(self, tmp_path: pathlib.Path) -> None:
724 _init_repo(tmp_path)
725 _commit_files(tmp_path, {"a.txt": b"hello\n"})
726 (tmp_path / "a.txt").write_bytes(b"hello\n")
727 result = _invoke(
728 ["content-grep", "hello", "--working-tree", "--json"],
729 env=_env(tmp_path),
730 )
731 data = json.loads(result.output)
732 assert data["commit_id"] is None
733
734 def test_snapshot_id_null_in_working_tree_mode(self, tmp_path: pathlib.Path) -> None:
735 _init_repo(tmp_path)
736 _commit_files(tmp_path, {"a.txt": b"hello\n"})
737 (tmp_path / "a.txt").write_bytes(b"hello\n")
738 result = _invoke(
739 ["content-grep", "hello", "--working-tree", "--json"],
740 env=_env(tmp_path),
741 )
742 data = json.loads(result.output)
743 assert data["snapshot_id"] is None
744
745 def test_exit_code_field_zero_on_match(self, tmp_path: pathlib.Path) -> None:
746 _init_repo(tmp_path)
747 _commit_files(tmp_path, {"a.txt": b"hello\n"})
748 result = _invoke(["content-grep", "hello", "--json"], env=_env(tmp_path))
749 data = json.loads(result.output)
750 assert data["exit_code"] == 0
751
752 def test_json_is_compact(self, tmp_path: pathlib.Path) -> None:
753 """JSON output must be a single line — no pretty-printing."""
754 _init_repo(tmp_path)
755 _commit_files(tmp_path, {"a.txt": b"hello\n"})
756 result = _invoke(["content-grep", "hello", "--json"], env=_env(tmp_path))
757 lines = [ln for ln in result.output.splitlines() if ln.strip()]
758 assert len(lines) == 1, "JSON must be compact (one line)"
759
760
761 # ---------------------------------------------------------------------------
762 # duration_ms (TestElapsedSeconds)
763 # ---------------------------------------------------------------------------
764
765
766 class TestElapsedSeconds:
767 """``duration_ms`` must be a non-negative float in all JSON paths."""
768
769 def _assert_elapsed(self, data: Mapping[str, object]) -> None: # type: ignore[type-arg]
770 assert "duration_ms" in data
771 assert isinstance(data["duration_ms"], float)
772 assert data["duration_ms"] >= 0.0
773
774 def test_elapsed_present_commit_mode(self, tmp_path: pathlib.Path) -> None:
775 _init_repo(tmp_path)
776 _commit_files(tmp_path, {"a.txt": b"target\n"})
777 result = _invoke(["content-grep", "target", "--json"], env=_env(tmp_path))
778 self._assert_elapsed(json.loads(result.output))
779
780 def test_elapsed_present_working_tree_mode(self, tmp_path: pathlib.Path) -> None:
781 _init_repo(tmp_path)
782 _commit_files(tmp_path, {"a.txt": b"target\n"})
783 (tmp_path / "a.txt").write_bytes(b"target\n")
784 result = _invoke(
785 ["content-grep", "target", "--working-tree", "--json"],
786 env=_env(tmp_path),
787 )
788 self._assert_elapsed(json.loads(result.output))
789
790 def test_elapsed_is_float_not_int(self, tmp_path: pathlib.Path) -> None:
791 _init_repo(tmp_path)
792 _commit_files(tmp_path, {"a.txt": b"target\n"})
793 result = _invoke(["content-grep", "target", "--json"], env=_env(tmp_path))
794 data = json.loads(result.output)
795 assert isinstance(data["duration_ms"], float)
796
797 def test_elapsed_reasonable_upper_bound(self, tmp_path: pathlib.Path) -> None:
798 """Single-file search in a temp repo should be well under 5 seconds."""
799 _init_repo(tmp_path)
800 _commit_files(tmp_path, {"a.txt": b"target\n"})
801 result = _invoke(["content-grep", "target", "--json"], env=_env(tmp_path))
802 data = json.loads(result.output)
803 assert data["duration_ms"] < 5.0
804
805 def test_elapsed_present_stress_mode(self, tmp_path: pathlib.Path) -> None:
806 """duration_ms must appear even for 500-file parallel searches."""
807 _init_repo(tmp_path)
808 files: Mapping[str, bytes] = {f"f{i}.txt": b"needle\n" for i in range(50)}
809 _commit_files(tmp_path, files)
810 result = _invoke(["content-grep", "needle", "--json"], env=_env(tmp_path))
811 assert result.exit_code == 0
812 self._assert_elapsed(json.loads(result.output))
813
814 def test_elapsed_six_decimal_places(self, tmp_path: pathlib.Path) -> None:
815 """duration_ms should be rounded to at most 6 decimal places."""
816 _init_repo(tmp_path)
817 _commit_files(tmp_path, {"a.txt": b"target\n"})
818 result = _invoke(["content-grep", "target", "--json"], env=_env(tmp_path))
819 data = json.loads(result.output)
820 elapsed = data["duration_ms"]
821 # round-trip through 6-decimal representation must be exact
822 assert round(elapsed, 6) == elapsed
823
824
825 # ---------------------------------------------------------------------------
826 # exit_code field (TestExitCode)
827 # ---------------------------------------------------------------------------
828
829
830 class TestExitCode:
831 """``exit_code`` in JSON must mirror the process exit code."""
832
833 def test_exit_code_zero_on_match(self, tmp_path: pathlib.Path) -> None:
834 _init_repo(tmp_path)
835 _commit_files(tmp_path, {"a.txt": b"hit\n"})
836 result = _invoke(["content-grep", "hit", "--json"], env=_env(tmp_path))
837 assert result.exit_code == 0
838 assert json.loads(result.output)["exit_code"] == 0
839
840 def test_exit_code_zero_working_tree_match(self, tmp_path: pathlib.Path) -> None:
841 _init_repo(tmp_path)
842 _commit_files(tmp_path, {"a.txt": b"hit\n"})
843 (tmp_path / "a.txt").write_bytes(b"hit\n")
844 result = _invoke(
845 ["content-grep", "hit", "--working-tree", "--json"],
846 env=_env(tmp_path),
847 )
848 assert result.exit_code == 0
849 assert json.loads(result.output)["exit_code"] == 0
850
851 def test_exit_code_is_integer(self, tmp_path: pathlib.Path) -> None:
852 _init_repo(tmp_path)
853 _commit_files(tmp_path, {"a.txt": b"hit\n"})
854 result = _invoke(["content-grep", "hit", "--json"], env=_env(tmp_path))
855 data = json.loads(result.output)
856 assert isinstance(data["exit_code"], int)
857
858 def test_exit_code_in_json_matches_process_exit(self, tmp_path: pathlib.Path) -> None:
859 """JSON exit_code must equal the actual process exit code."""
860 _init_repo(tmp_path)
861 _commit_files(tmp_path, {"a.txt": b"hit\n"})
862 result = _invoke(["content-grep", "hit", "--json"], env=_env(tmp_path))
863 data = json.loads(result.output)
864 assert data["exit_code"] == result.exit_code
865
866 def test_exit_code_multiple_files(self, tmp_path: pathlib.Path) -> None:
867 _init_repo(tmp_path)
868 _commit_files(tmp_path, {"a.txt": b"hit\n", "b.txt": b"hit\n"})
869 result = _invoke(["content-grep", "hit", "--json"], env=_env(tmp_path))
870 assert result.exit_code == 0
871 assert json.loads(result.output)["exit_code"] == 0
872
873
874 # ---------------------------------------------------------------------------
875 # Flag registration tests
876 # ---------------------------------------------------------------------------
877
878 import argparse as _argparse
879 from muse.cli.commands.content_grep import register as _register_content_grep
880
881
882 def _parse_cgrep(*args: str) -> _argparse.Namespace:
883 root_p = _argparse.ArgumentParser()
884 subs = root_p.add_subparsers(dest="cmd")
885 _register_content_grep(subs)
886 return root_p.parse_args(["content-grep", *args])
887
888
889 class TestRegisterFlags:
890 def test_default_json_out_is_false(self) -> None:
891 ns = _parse_cgrep("TODO")
892 assert ns.json_out is False
893
894 def test_json_flag_sets_json_out(self) -> None:
895 ns = _parse_cgrep("TODO", "--json")
896 assert ns.json_out is True
897
898 def test_j_shorthand_sets_json_out(self) -> None:
899 ns = _parse_cgrep("TODO", "-j")
900 assert ns.json_out is True
901
902 def test_pattern_positional(self) -> None:
903 ns = _parse_cgrep("FIXME")
904 assert ns.pattern == "FIXME"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago