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