gabriel / muse public
test_plumbing_check_ignore.py python
459 lines 18.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive tests for ``muse plumbing check-ignore``.
2
3 Audit findings addressed here
4 ------------------------------
5 Code quality
6 - ``_check_path`` and ``_posix_match`` private helpers in check_ignore.py
7 duplicated ``is_ignored`` and ``_matches`` from ``muse.core.ignore``.
8 Both have been deleted; the CLI now delegates to
9 ``check_path_with_pattern`` — the single authoritative matching function.
10 - ``is_ignored`` in core now delegates to ``check_path_with_pattern``,
11 guaranteeing the CLI and the snapshot engine always agree.
12
13 Security
14 - Format error now goes to stderr.
15 - ANSI injection in path / matching_pattern stripped in text mode.
16 - Null bytes in paths rejected with USER_ERROR.
17
18 Agent UX
19 - ``--stdin`` — read paths one-per-line from stdin.
20 - ``--patterns-only`` — emit resolved patterns without testing any path.
21 - ``formatter_class`` added for clean --help output.
22
23 Coverage tiers
24 --------------
25 - Unit: check_path_with_pattern (core), is_ignored delegation,
26 _PathResult schema
27 - Integration: JSON/text format, --quiet, --verbose, --stdin, --patterns-only,
28 empty ignore file, global patterns, domain patterns, negation,
29 directory patterns, anchored patterns
30 - Security: null byte paths rejected, ANSI stripped, format error→stderr,
31 no traceback on bad input
32 - Stress: 1 000-path batch, 200 sequential runs, 50-pattern rule set
33 """
34 from __future__ import annotations
35
36 import json
37 import pathlib
38
39 import pytest
40
41 from muse.core.errors import ExitCode
42 from tests.cli_test_helper import CliRunner, InvokeResult
43
44 runner = CliRunner()
45
46
47 # ---------------------------------------------------------------------------
48 # Helpers
49 # ---------------------------------------------------------------------------
50
51 def _make_repo(tmp_path: pathlib.Path, domain: str = "code") -> pathlib.Path:
52 repo = tmp_path / "repo"
53 muse = repo / ".muse"
54 for sub in ("objects", "commits", "snapshots", "refs/heads"):
55 (muse / sub).mkdir(parents=True)
56 (muse / "HEAD").write_text("ref: refs/heads/main")
57 (muse / "repo.json").write_text(json.dumps({"repo_id": "r1", "domain": domain}))
58 return repo
59
60
61 def _write_museignore(repo: pathlib.Path, content: str) -> None:
62 (repo / ".museignore").write_text(content)
63
64
65 def _ci(repo: pathlib.Path, *args: str, stdin: str | None = None) -> InvokeResult:
66 from muse.cli.app import main as cli
67 return runner.invoke(
68 cli,
69 ["check-ignore", *args],
70 env={"MUSE_REPO_ROOT": str(repo)},
71 input=stdin,
72 )
73
74
75 # ---------------------------------------------------------------------------
76 # Unit — check_path_with_pattern (core)
77 # ---------------------------------------------------------------------------
78
79
80 class TestCheckPathWithPattern:
81 """The single authoritative ignore-matching function lives in core."""
82
83 def test_no_patterns_not_ignored(self) -> None:
84 from muse.core.ignore import check_path_with_pattern
85 ignored, pat = check_path_with_pattern("tracks/drums.mid", [])
86 assert not ignored
87 assert pat is None
88
89 def test_simple_glob_match(self) -> None:
90 from muse.core.ignore import check_path_with_pattern
91 ignored, pat = check_path_with_pattern("build/out.bin", ["build/"])
92 assert ignored
93 assert pat == "build/"
94
95 def test_negation_un_ignores(self) -> None:
96 from muse.core.ignore import check_path_with_pattern
97 ignored, pat = check_path_with_pattern(
98 "build/keep.mid", ["build/", "!build/keep.mid"]
99 )
100 assert not ignored
101 assert pat is None # negated → no active pattern
102
103 def test_extension_glob(self) -> None:
104 from muse.core.ignore import check_path_with_pattern
105 ignored, pat = check_path_with_pattern("tracks/drums.tmp", ["*.tmp"])
106 assert ignored
107 assert pat == "*.tmp"
108
109 def test_last_match_wins(self) -> None:
110 from muse.core.ignore import check_path_with_pattern
111 ignored, pat = check_path_with_pattern(
112 "foo.log", ["*.log", "!foo.log", "foo.*"]
113 )
114 assert ignored
115 assert pat == "foo.*"
116
117 def test_anchored_pattern(self) -> None:
118 from muse.core.ignore import check_path_with_pattern
119 ignored, pat = check_path_with_pattern("dist/index.js", ["/dist/index.js"])
120 assert ignored
121 assert pat == "/dist/index.js"
122
123 def test_non_matching_returns_false(self) -> None:
124 from muse.core.ignore import check_path_with_pattern
125 ignored, pat = check_path_with_pattern("tracks/drums.mid", ["*.tmp"])
126 assert not ignored
127 assert pat is None
128
129
130 class TestIsIgnoredDelegates:
131 """is_ignored must agree with check_path_with_pattern on every case."""
132
133 def test_delegation_matches(self) -> None:
134 from muse.core.ignore import check_path_with_pattern, is_ignored
135 patterns = ["build/", "*.log", "!tracks/*.mid"]
136 paths = [
137 "build/out.bin",
138 "app.log",
139 "tracks/drums.mid",
140 "other.py",
141 ]
142 for p in paths:
143 ignored_via_delegate = is_ignored(p, patterns)
144 ignored_via_direct, _ = check_path_with_pattern(p, patterns)
145 assert ignored_via_delegate == ignored_via_direct, (
146 f"is_ignored and check_path_with_pattern disagree on {p!r}"
147 )
148
149
150 class TestPathResultSchema:
151 def test_fields(self) -> None:
152 from muse.cli.commands.plumbing.check_ignore import _PathResult
153 fields = set(_PathResult.__annotations__)
154 assert fields == {"path", "ignored", "matching_pattern"}
155
156
157 # ---------------------------------------------------------------------------
158 # Integration — JSON output
159 # ---------------------------------------------------------------------------
160
161
162 class TestJsonOutput:
163 def test_no_ignore_file_returns_not_ignored(self, tmp_path: pathlib.Path) -> None:
164 repo = _make_repo(tmp_path)
165 result = _ci(repo, "tracks/drums.mid")
166 assert result.exit_code == 0
167 data = json.loads(result.output)
168 assert data["patterns_loaded"] == 0
169 assert data["results"][0]["ignored"] is False
170 assert data["results"][0]["matching_pattern"] is None
171
172 def test_ignored_path(self, tmp_path: pathlib.Path) -> None:
173 repo = _make_repo(tmp_path)
174 _write_museignore(repo, '[global]\npatterns = ["build/"]\n')
175 data = json.loads(_ci(repo, "build/out.bin").output)
176 assert data["results"][0]["ignored"] is True
177 assert data["results"][0]["matching_pattern"] == "build/"
178
179 def test_multiple_paths(self, tmp_path: pathlib.Path) -> None:
180 repo = _make_repo(tmp_path)
181 _write_museignore(repo, '[global]\npatterns = ["*.tmp"]\n')
182 data = json.loads(_ci(repo, "a.tmp", "b.mid").output)
183 assert len(data["results"]) == 2
184 assert data["results"][0]["ignored"] is True
185 assert data["results"][1]["ignored"] is False
186
187 def test_domain_in_output(self, tmp_path: pathlib.Path) -> None:
188 repo = _make_repo(tmp_path, domain="code")
189 data = json.loads(_ci(repo, "foo.py").output)
190 assert data["domain"] == "code"
191
192 def test_domain_specific_patterns(self, tmp_path: pathlib.Path) -> None:
193 repo = _make_repo(tmp_path, domain="midi")
194 _write_museignore(repo, '[domain.midi]\npatterns = ["*.log"]\n')
195 data = json.loads(_ci(repo, "debug.log").output)
196 assert data["results"][0]["ignored"] is True
197
198 def test_domain_patterns_not_applied_to_other_domain(self, tmp_path: pathlib.Path) -> None:
199 repo = _make_repo(tmp_path, domain="code")
200 _write_museignore(repo, '[domain.midi]\npatterns = ["*.log"]\n')
201 data = json.loads(_ci(repo, "debug.log").output)
202 assert data["results"][0]["ignored"] is False
203
204 def test_negation_pattern(self, tmp_path: pathlib.Path) -> None:
205 repo = _make_repo(tmp_path)
206 _write_museignore(
207 repo, '[global]\npatterns = ["build/", "!build/keep.mid"]\n'
208 )
209 data = json.loads(_ci(repo, "build/keep.mid").output)
210 assert data["results"][0]["ignored"] is False
211 assert data["results"][0]["matching_pattern"] is None
212
213 def test_json_shorthand(self, tmp_path: pathlib.Path) -> None:
214 repo = _make_repo(tmp_path)
215 result = _ci(repo, "--json", "foo.py")
216 assert result.exit_code == 0
217 assert "results" in json.loads(result.output)
218
219
220 # ---------------------------------------------------------------------------
221 # Integration — text output
222 # ---------------------------------------------------------------------------
223
224
225 class TestTextOutput:
226 def test_ignored_shows_ignored_label(self, tmp_path: pathlib.Path) -> None:
227 repo = _make_repo(tmp_path)
228 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
229 result = _ci(repo, "--format", "text", "out.bin")
230 assert result.exit_code == 0
231 assert "ignored" in result.output
232
233 def test_not_ignored_shows_ok(self, tmp_path: pathlib.Path) -> None:
234 repo = _make_repo(tmp_path)
235 result = _ci(repo, "--format", "text", "main.py")
236 assert "ok" in result.output
237
238 def test_verbose_shows_pattern(self, tmp_path: pathlib.Path) -> None:
239 repo = _make_repo(tmp_path)
240 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
241 result = _ci(repo, "--format", "text", "--verbose", "out.bin")
242 assert "[*.bin]" in result.output
243
244 def test_verbose_no_pattern_when_not_ignored(self, tmp_path: pathlib.Path) -> None:
245 repo = _make_repo(tmp_path)
246 result = _ci(repo, "--format", "text", "--verbose", "main.py")
247 assert "[" not in result.output
248
249
250 # ---------------------------------------------------------------------------
251 # Integration — --quiet mode
252 # ---------------------------------------------------------------------------
253
254
255 class TestQuietMode:
256 def test_all_ignored_exits_0(self, tmp_path: pathlib.Path) -> None:
257 repo = _make_repo(tmp_path)
258 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
259 result = _ci(repo, "--quiet", "a.bin", "b.bin")
260 assert result.exit_code == 0
261 assert result.output.strip() == ""
262
263 def test_some_not_ignored_exits_1(self, tmp_path: pathlib.Path) -> None:
264 repo = _make_repo(tmp_path)
265 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
266 result = _ci(repo, "--quiet", "a.bin", "main.py")
267 assert result.exit_code == ExitCode.USER_ERROR
268
269 def test_none_ignored_exits_1(self, tmp_path: pathlib.Path) -> None:
270 repo = _make_repo(tmp_path)
271 result = _ci(repo, "--quiet", "main.py")
272 assert result.exit_code == ExitCode.USER_ERROR
273
274
275 # ---------------------------------------------------------------------------
276 # Integration — --stdin (new agent UX)
277 # ---------------------------------------------------------------------------
278
279
280 class TestStdinMode:
281 def test_reads_paths_from_stdin(self, tmp_path: pathlib.Path) -> None:
282 repo = _make_repo(tmp_path)
283 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
284 result = _ci(repo, "--stdin", stdin="a.bin\nb.py\n")
285 assert result.exit_code == 0
286 data = json.loads(result.output)
287 assert len(data["results"]) == 2
288 assert data["results"][0]["ignored"] is True
289 assert data["results"][1]["ignored"] is False
290
291 def test_blank_lines_and_comments_skipped(self, tmp_path: pathlib.Path) -> None:
292 repo = _make_repo(tmp_path)
293 result = _ci(repo, "--stdin", stdin="\n# comment\nfoo.py\n\n")
294 data = json.loads(result.output)
295 assert len(data["results"]) == 1
296 assert data["results"][0]["path"] == "foo.py"
297
298 def test_stdin_combines_with_positional(self, tmp_path: pathlib.Path) -> None:
299 repo = _make_repo(tmp_path)
300 result = _ci(repo, "--stdin", "positional.py", stdin="from_stdin.py\n")
301 data = json.loads(result.output)
302 paths = [r["path"] for r in data["results"]]
303 assert "positional.py" in paths
304 assert "from_stdin.py" in paths
305
306 def test_no_paths_and_no_stdin_content_errors(self, tmp_path: pathlib.Path) -> None:
307 """--stdin with empty stdin and no positional args should error."""
308 repo = _make_repo(tmp_path)
309 result = _ci(repo, "--stdin", stdin="")
310 assert result.exit_code == ExitCode.USER_ERROR
311
312
313 # ---------------------------------------------------------------------------
314 # Integration — --patterns-only (new agent UX)
315 # ---------------------------------------------------------------------------
316
317
318 class TestPatternsOnly:
319 def test_json_patterns_list(self, tmp_path: pathlib.Path) -> None:
320 repo = _make_repo(tmp_path)
321 _write_museignore(repo, '[global]\npatterns = ["build/", "*.tmp"]\n')
322 data = json.loads(_ci(repo, "--patterns-only").output)
323 assert "patterns" in data
324 assert "build/" in data["patterns"]
325 assert "*.tmp" in data["patterns"]
326 assert data["domain"] == "code"
327
328 def test_text_patterns_one_per_line(self, tmp_path: pathlib.Path) -> None:
329 repo = _make_repo(tmp_path)
330 _write_museignore(repo, '[global]\npatterns = ["build/", "*.tmp"]\n')
331 result = _ci(repo, "--patterns-only", "--format", "text")
332 assert result.exit_code == 0
333 lines = [l for l in result.output.splitlines() if l.strip()]
334 assert "build/" in lines
335 assert "*.tmp" in lines
336
337 def test_empty_museignore_empty_list(self, tmp_path: pathlib.Path) -> None:
338 repo = _make_repo(tmp_path)
339 data = json.loads(_ci(repo, "--patterns-only").output)
340 assert data["patterns"] == []
341
342 def test_no_path_required(self, tmp_path: pathlib.Path) -> None:
343 """--patterns-only should not require any path arguments."""
344 repo = _make_repo(tmp_path)
345 result = _ci(repo, "--patterns-only")
346 assert result.exit_code == 0
347
348
349 # ---------------------------------------------------------------------------
350 # Security
351 # ---------------------------------------------------------------------------
352
353
354 class TestSecurity:
355 def test_null_byte_in_path_rejected(self, tmp_path: pathlib.Path) -> None:
356 repo = _make_repo(tmp_path)
357 result = _ci(repo, "tracks/\x00evil.mid")
358 assert result.exit_code == ExitCode.USER_ERROR
359 assert "null byte" in result.output.lower()
360
361 def test_ansi_in_path_stripped_text(self, tmp_path: pathlib.Path) -> None:
362 repo = _make_repo(tmp_path)
363 result = _ci(repo, "--format", "text", "\x1b[31mevil\x1b[0m.py")
364 assert "\x1b" not in result.output
365
366 def test_ansi_in_pattern_stripped_text(self, tmp_path: pathlib.Path) -> None:
367 repo = _make_repo(tmp_path)
368 _write_museignore(repo, '[global]\npatterns = ["\\u001b[31m*.bin\\u001b[0m"]\n')
369 result = _ci(repo, "--format", "text", "--verbose", "evil.bin")
370 assert "\x1b" not in result.output
371
372 def test_format_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
373 repo = _make_repo(tmp_path)
374 result = _ci(repo, "--format", "yaml", "foo.py")
375 assert result.exit_code == ExitCode.USER_ERROR
376 assert "error" in result.stderr.lower()
377 assert result.stdout_bytes == b""
378
379 def test_no_traceback_on_bad_format(self, tmp_path: pathlib.Path) -> None:
380 repo = _make_repo(tmp_path)
381 result = _ci(repo, "--format", "xml", "foo.py")
382 assert "Traceback" not in result.output
383
384 def test_no_paths_errors_to_stderr(self, tmp_path: pathlib.Path) -> None:
385 """Calling with no paths should report error on stderr."""
386 repo = _make_repo(tmp_path)
387 result = _ci(repo)
388 assert result.exit_code == ExitCode.USER_ERROR
389 assert "error" in result.stderr.lower()
390
391 def test_invalid_toml_errors(self, tmp_path: pathlib.Path) -> None:
392 repo = _make_repo(tmp_path)
393 (repo / ".museignore").write_text("[broken toml !!!")
394 result = _ci(repo, "foo.py")
395 assert result.exit_code == ExitCode.INTERNAL_ERROR
396 assert "Traceback" not in result.output
397
398 def test_path_traversal_is_safe(self, tmp_path: pathlib.Path) -> None:
399 """Path-traversal inputs are rejected with USER_ERROR — no crash, no file access."""
400 repo = _make_repo(tmp_path)
401 result = _ci(repo, "../../../etc/passwd")
402 assert result.exit_code == 1 # USER_ERROR — traversal blocked
403 data = json.loads(result.output)
404 assert "error" in data
405 assert ".." in data["error"]
406
407 def test_very_long_path_no_crash(self, tmp_path: pathlib.Path) -> None:
408 repo = _make_repo(tmp_path)
409 long_path = "a/" * 200 + "file.py"
410 result = _ci(repo, long_path)
411 assert result.exit_code == 0
412
413
414 # ---------------------------------------------------------------------------
415 # Stress
416 # ---------------------------------------------------------------------------
417
418
419 class TestStress:
420 def test_1000_paths(self, tmp_path: pathlib.Path) -> None:
421 repo = _make_repo(tmp_path)
422 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
423 paths = [f"file_{i:04d}.bin" for i in range(500)]
424 paths += [f"file_{i:04d}.py" for i in range(500)]
425 result = _ci(repo, *paths)
426 assert result.exit_code == 0
427 data = json.loads(result.output)
428 assert len(data["results"]) == 1000
429 ignored_count = sum(1 for r in data["results"] if r["ignored"])
430 assert ignored_count == 500
431
432 def test_50_pattern_rule_set(self, tmp_path: pathlib.Path) -> None:
433 repo = _make_repo(tmp_path)
434 patterns = [f"dir_{i}/" for i in range(25)] + [f"*.ext{i}" for i in range(25)]
435 patterns_toml = json.dumps(patterns)
436 _write_museignore(repo, f"[global]\npatterns = {patterns_toml}\n")
437 data = json.loads(_ci(repo, "dir_0/file.txt", "file.ext0", "clean.py").output)
438 assert data["patterns_loaded"] == 50
439 assert data["results"][0]["ignored"] is True
440 assert data["results"][1]["ignored"] is True
441 assert data["results"][2]["ignored"] is False
442
443 def test_200_sequential_runs(self, tmp_path: pathlib.Path) -> None:
444 repo = _make_repo(tmp_path)
445 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
446 for i in range(200):
447 result = _ci(repo, "out.bin")
448 assert result.exit_code == 0, f"failed at iteration {i}"
449 assert json.loads(result.output)["results"][0]["ignored"] is True
450
451 def test_stdin_1000_paths(self, tmp_path: pathlib.Path) -> None:
452 repo = _make_repo(tmp_path)
453 _write_museignore(repo, '[global]\npatterns = ["*.bin"]\n')
454 stdin_input = "\n".join(f"file_{i}.bin" for i in range(1000)) + "\n"
455 result = _ci(repo, "--stdin", stdin=stdin_input)
456 assert result.exit_code == 0
457 data = json.loads(result.output)
458 assert len(data["results"]) == 1000
459 assert all(r["ignored"] for r in data["results"])
File History 3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago