gabriel / muse public
test_cmd_check_attr.py python
696 lines 26.5 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Comprehensive tests for ``muse check-attr``.
2
3 Audit findings addressed here
4 ------------------------------
5 Performance
6 - Default mode previously called ``resolve_strategy`` + ``_find_matching_rule``
7 separately → 2× rule-list iteration per path. Replaced with
8 ``_resolve_with_rule`` — single O(N) pass returning (strategy, rule).
9
10 Code quality
11 - ``--all-rules`` manual dim-match loop extracted into ``_all_matching_rules``.
12 - ``_dim_match`` helper eliminates repeated boolean expression.
13
14 Security
15 - Format error now goes to stderr (was stdout) — verified below.
16 - ANSI injection in text output stripped via sanitize_display().
17 - Null bytes in paths now rejected with USER_ERROR.
18
19 Agent UX
20 - ``--stdin`` — read paths from stdin (one per line, # comments skipped).
21 - ``--rules-only`` — emit loaded rules without testing paths.
22 - ``formatter_class`` added for clean --help rendering.
23 - ``nargs="*"`` on paths (was "+") to support --stdin and --rules-only.
24
25 Coverage tiers
26 --------------
27 - Unit: _dim_match, _resolve_with_rule, _all_matching_rules, _rule_to_dict,
28 _RuleDict / _PathResult schemas
29 - Integration: JSON/text formats, --dimension filter, --all-rules, --rules-only,
30 --stdin, empty .museattributes, multiple rules priority, negation
31 - Security: null bytes rejected, ANSI stripped in text, format error→stderr,
32 no tracebacks, bad TOML errors cleanly
33 - Stress: 1 000-path batch, 50-rule set, 200 sequential runs, stdin 1 000 paths
34 """
35 from __future__ import annotations
36
37 import json
38 import pathlib
39
40 import pytest
41
42 from muse.core.attributes import AttributeRule
43 from muse.core.paths import muse_dir
44 from muse.core.errors import ExitCode
45 from tests.cli_test_helper import CliRunner, InvokeResult
46
47 runner = CliRunner()
48
49
50 # ---------------------------------------------------------------------------
51 # Helpers
52 # ---------------------------------------------------------------------------
53
54 def _make_repo(tmp_path: pathlib.Path, domain: str = "code") -> pathlib.Path:
55 repo = tmp_path / "repo"
56 dot_muse = muse_dir(repo)
57 for sub in ("objects", "commits", "snapshots", "refs/heads"):
58 (dot_muse / sub).mkdir(parents=True)
59 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
60 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "r1", "domain": domain}))
61 return repo
62
63
64 def _write_attrs(repo: pathlib.Path, content: str) -> None:
65 (repo / ".museattributes").write_text(content)
66
67
68 _SIMPLE_ATTRS = """
69 [meta]
70 domain = "code"
71
72 [[rules]]
73 path = "build/*"
74 dimension = "*"
75 strategy = "ours"
76 comment = "Build artifacts prefer ours."
77 priority = 10
78
79 [[rules]]
80 path = "*.md"
81 dimension = "*"
82 strategy = "union"
83 priority = 5
84 """
85
86
87 def _ca(repo: pathlib.Path, *args: str, stdin: str | None = None) -> InvokeResult:
88 from muse.cli.app import main as cli
89 return runner.invoke(
90 cli,
91 ["check-attr", *args],
92 env={"MUSE_REPO_ROOT": str(repo)},
93 input=stdin,
94 )
95
96
97 def _make_rule(
98 path_pattern: str = "*",
99 dimension: str = "*",
100 strategy: str = "ours",
101 priority: int = 0,
102 source_index: int = 0,
103 ) -> AttributeRule:
104 from dataclasses import dataclass
105 return AttributeRule(
106 path_pattern=path_pattern,
107 dimension=dimension,
108 strategy=strategy,
109 priority=priority,
110 source_index=source_index,
111 )
112
113
114 # ---------------------------------------------------------------------------
115 # Unit — private helpers
116 # ---------------------------------------------------------------------------
117
118
119 class TestDimMatch:
120 def test_wildcard_rule_matches_any(self) -> None:
121 from muse.cli.commands.check_attr import _dim_match
122 rule = _make_rule(dimension="*")
123 assert _dim_match(rule, "notes")
124 assert _dim_match(rule, "pitch_bend")
125
126 def test_exact_dim_matches(self) -> None:
127 from muse.cli.commands.check_attr import _dim_match
128 rule = _make_rule(dimension="notes")
129 assert _dim_match(rule, "notes")
130
131 def test_exact_dim_no_match(self) -> None:
132 from muse.cli.commands.check_attr import _dim_match
133 rule = _make_rule(dimension="notes")
134 assert not _dim_match(rule, "pitch_bend")
135
136 def test_wildcard_query_matches_any_rule(self) -> None:
137 from muse.cli.commands.check_attr import _dim_match
138 rule = _make_rule(dimension="notes")
139 assert _dim_match(rule, "*")
140
141
142 class TestResolveWithRule:
143 def test_no_rules_returns_auto(self) -> None:
144 from muse.cli.commands.check_attr import _resolve_with_rule
145 strategy, rule = _resolve_with_rule([], "tracks/drums.mid", "*")
146 assert strategy == "auto"
147 assert rule is None
148
149 def test_matching_rule_returned(self) -> None:
150 from muse.cli.commands.check_attr import _resolve_with_rule
151 rules = [_make_rule(path_pattern="build/*", strategy="ours")]
152 strategy, rule = _resolve_with_rule(rules, "build/out.bin", "*")
153 assert strategy == "ours"
154 assert rule is not None
155 assert rule.path_pattern == "build/*"
156
157 def test_non_matching_path_returns_auto(self) -> None:
158 from muse.cli.commands.check_attr import _resolve_with_rule
159 rules = [_make_rule(path_pattern="build/*", strategy="ours")]
160 strategy, rule = _resolve_with_rule(rules, "tracks/drums.mid", "*")
161 assert strategy == "auto"
162 assert rule is None
163
164 def test_first_match_wins(self) -> None:
165 from muse.cli.commands.check_attr import _resolve_with_rule
166 rules = [
167 _make_rule(path_pattern="*.mid", strategy="ours", priority=10),
168 _make_rule(path_pattern="tracks/*", strategy="theirs", priority=5),
169 ]
170 strategy, rule = _resolve_with_rule(rules, "tracks/drums.mid", "*")
171 assert strategy == "ours"
172
173 def test_dimension_filter_respected(self) -> None:
174 from muse.cli.commands.check_attr import _resolve_with_rule
175 rules = [_make_rule(path_pattern="*", dimension="notes", strategy="union")]
176 strategy, rule = _resolve_with_rule(rules, "tracks/drums.mid", "pitch_bend")
177 assert strategy == "auto"
178 assert rule is None
179
180
181 class TestAllMatchingRules:
182 def test_returns_all_matches(self) -> None:
183 from muse.cli.commands.check_attr import _all_matching_rules
184 rules = [
185 _make_rule(path_pattern="*.mid", strategy="ours"),
186 _make_rule(path_pattern="tracks/*", strategy="theirs"),
187 _make_rule(path_pattern="build/*", strategy="union"),
188 ]
189 matched = _all_matching_rules(rules, "tracks/drums.mid", "*")
190 assert len(matched) == 2
191
192 def test_empty_when_no_match(self) -> None:
193 from muse.cli.commands.check_attr import _all_matching_rules
194 rules = [_make_rule(path_pattern="build/*", strategy="ours")]
195 assert _all_matching_rules(rules, "tracks/drums.mid", "*") == []
196
197
198 class TestRuleToDict:
199 def test_all_fields_present(self) -> None:
200 from muse.cli.commands.check_attr import _rule_to_dict
201 rule = _make_rule(path_pattern="*.mid", dimension="notes", strategy="ours",
202 priority=5, source_index=2)
203 d = _rule_to_dict(rule)
204 assert d["path_pattern"] == "*.mid"
205 assert d["dimension"] == "notes"
206 assert d["strategy"] == "ours"
207 assert d["priority"] == 5
208 assert d["source_index"] == 2
209
210
211 class TestSchemas:
212 def test_path_result_fields(self) -> None:
213 from muse.cli.commands.check_attr import _PathResult
214 fields = set(_PathResult.__annotations__)
215 assert fields == {"path", "dimension", "strategy", "rule"}
216
217 def test_rule_dict_fields(self) -> None:
218 from muse.cli.commands.check_attr import _RuleDict
219 fields = set(_RuleDict.__annotations__)
220 assert {"path_pattern", "dimension", "strategy", "comment",
221 "priority", "source_index"} == fields
222
223
224 # ---------------------------------------------------------------------------
225 # Integration — JSON output (default mode)
226 # ---------------------------------------------------------------------------
227
228
229 class TestJsonOutput:
230 def test_no_attrs_file_returns_auto(self, tmp_path: pathlib.Path) -> None:
231 repo = _make_repo(tmp_path)
232 result = _ca(repo, "--json", "tracks/drums.mid")
233 assert result.exit_code == 0
234 data = json.loads(result.output)
235 assert data["rules_loaded"] == 0
236 assert data["results"][0]["strategy"] == "auto"
237 assert data["results"][0]["rule"] is None
238
239 def test_matching_rule_present(self, tmp_path: pathlib.Path) -> None:
240 repo = _make_repo(tmp_path)
241 _write_attrs(repo, _SIMPLE_ATTRS)
242 data = json.loads(_ca(repo, "--json", "build/out.bin").output)
243 assert data["results"][0]["strategy"] == "ours"
244 assert data["results"][0]["rule"] is not None
245 assert data["results"][0]["rule"]["path_pattern"] == "build/*"
246
247 def test_non_matching_path_auto(self, tmp_path: pathlib.Path) -> None:
248 repo = _make_repo(tmp_path)
249 _write_attrs(repo, _SIMPLE_ATTRS)
250 data = json.loads(_ca(repo, "--json", "tracks/drums.mid").output)
251 assert data["results"][0]["strategy"] == "auto"
252
253 def test_multiple_paths(self, tmp_path: pathlib.Path) -> None:
254 repo = _make_repo(tmp_path)
255 _write_attrs(repo, _SIMPLE_ATTRS)
256 data = json.loads(_ca(repo, "--json", "build/out.bin", "README.md", "main.py").output)
257 assert len(data["results"]) == 3
258 strategies = [r["strategy"] for r in data["results"]]
259 assert strategies == ["ours", "union", "auto"]
260
261 def test_json_shorthand(self, tmp_path: pathlib.Path) -> None:
262 repo = _make_repo(tmp_path)
263 result = _ca(repo, "--json", "foo.py")
264 assert result.exit_code == 0
265 assert "results" in json.loads(result.output)
266
267 def test_dimension_filter(self, tmp_path: pathlib.Path) -> None:
268 repo = _make_repo(tmp_path)
269 _write_attrs(repo, """
270 [[rules]]
271 path = "tracks/*"
272 dimension = "notes"
273 strategy = "union"
274 """)
275 data = json.loads(_ca(repo, "--json", "--dimension", "notes", "tracks/drums.mid").output)
276 assert data["results"][0]["strategy"] == "union"
277
278 def test_dimension_no_match_auto(self, tmp_path: pathlib.Path) -> None:
279 repo = _make_repo(tmp_path)
280 _write_attrs(repo, """
281 [[rules]]
282 path = "tracks/*"
283 dimension = "notes"
284 strategy = "union"
285 """)
286 data = json.loads(_ca(repo, "--json", "--dimension", "pitch_bend", "tracks/drums.mid").output)
287 assert data["results"][0]["strategy"] == "auto"
288
289
290 # ---------------------------------------------------------------------------
291 # Integration — text output
292 # ---------------------------------------------------------------------------
293
294
295 class TestTextOutput:
296 def test_strategy_in_output(self, tmp_path: pathlib.Path) -> None:
297 repo = _make_repo(tmp_path)
298 _write_attrs(repo, _SIMPLE_ATTRS)
299 result = _ca(repo, "build/out.bin")
300 assert result.exit_code == 0
301 assert "strategy=ours" in result.output
302
303 def test_no_matching_rule_label(self, tmp_path: pathlib.Path) -> None:
304 repo = _make_repo(tmp_path)
305 result = _ca(repo, "main.py")
306 assert "no matching rule" in result.output
307
308
309 # ---------------------------------------------------------------------------
310 # Integration — --all-rules
311 # ---------------------------------------------------------------------------
312
313
314 class TestAllRules:
315 def test_all_matching_rules_returned(self, tmp_path: pathlib.Path) -> None:
316 repo = _make_repo(tmp_path)
317 _write_attrs(repo, """
318 [[rules]]
319 path = "tracks/*"
320 dimension = "*"
321 strategy = "ours"
322
323 [[rules]]
324 path = "*.mid"
325 dimension = "*"
326 strategy = "union"
327 """)
328 data = json.loads(_ca(repo, "--json", "--all-rules", "tracks/drums.mid").output)
329 result = data["results"][0]
330 assert len(result["matching_rules"]) == 2
331
332 def test_no_matching_rules(self, tmp_path: pathlib.Path) -> None:
333 repo = _make_repo(tmp_path)
334 _write_attrs(repo, _SIMPLE_ATTRS)
335 data = json.loads(_ca(repo, "--json", "--all-rules", "main.py").output)
336 assert data["results"][0]["matching_rules"] == []
337
338 def test_all_rules_text_output(self, tmp_path: pathlib.Path) -> None:
339 repo = _make_repo(tmp_path)
340 _write_attrs(repo, _SIMPLE_ATTRS)
341 result = _ca(repo, "--all-rules", "build/a.bin")
342 assert "strategy=ours" in result.output
343
344
345 # ---------------------------------------------------------------------------
346 # Integration — --rules-only (new agent UX)
347 # ---------------------------------------------------------------------------
348
349
350 class TestRulesOnly:
351 def test_json_rules_list(self, tmp_path: pathlib.Path) -> None:
352 repo = _make_repo(tmp_path)
353 _write_attrs(repo, _SIMPLE_ATTRS)
354 data = json.loads(_ca(repo, "--json", "--rules-only").output)
355 assert "rules" in data
356 assert len(data["rules"]) == 2
357 assert data["domain"] == "code"
358
359 def test_empty_attrs_empty_list(self, tmp_path: pathlib.Path) -> None:
360 repo = _make_repo(tmp_path)
361 data = json.loads(_ca(repo, "--json", "--rules-only").output)
362 assert data["rules"] == []
363 assert data["rules_loaded"] == 0
364
365 def test_text_format(self, tmp_path: pathlib.Path) -> None:
366 repo = _make_repo(tmp_path)
367 _write_attrs(repo, _SIMPLE_ATTRS)
368 result = _ca(repo, "--rules-only")
369 assert result.exit_code == 0
370 assert "strategy=ours" in result.output
371
372 def test_no_path_required(self, tmp_path: pathlib.Path) -> None:
373 repo = _make_repo(tmp_path)
374 result = _ca(repo, "--rules-only")
375 assert result.exit_code == 0
376
377
378 # ---------------------------------------------------------------------------
379 # Integration — --stdin (new agent UX)
380 # ---------------------------------------------------------------------------
381
382
383 class TestStdinMode:
384 def test_reads_paths_from_stdin(self, tmp_path: pathlib.Path) -> None:
385 repo = _make_repo(tmp_path)
386 _write_attrs(repo, _SIMPLE_ATTRS)
387 result = _ca(repo, "--json", "--stdin", stdin="build/out.bin\nmain.py\n")
388 data = json.loads(result.output)
389 assert len(data["results"]) == 2
390 assert data["results"][0]["strategy"] == "ours"
391 assert data["results"][1]["strategy"] == "auto"
392
393 def test_blank_lines_and_comments_skipped(self, tmp_path: pathlib.Path) -> None:
394 repo = _make_repo(tmp_path)
395 result = _ca(repo, "--json", "--stdin", stdin="\n# comment\nmain.py\n\n")
396 data = json.loads(result.output)
397 assert len(data["results"]) == 1
398
399 def test_stdin_combines_with_positional(self, tmp_path: pathlib.Path) -> None:
400 repo = _make_repo(tmp_path)
401 result = _ca(repo, "--json", "--stdin", "positional.py", stdin="from_stdin.py\n")
402 data = json.loads(result.output)
403 paths = [r["path"] for r in data["results"]]
404 assert "positional.py" in paths
405 assert "from_stdin.py" in paths
406
407 def test_empty_stdin_no_positional_errors(self, tmp_path: pathlib.Path) -> None:
408 repo = _make_repo(tmp_path)
409 result = _ca(repo, "--stdin", stdin="")
410 assert result.exit_code == ExitCode.USER_ERROR
411
412
413 # ---------------------------------------------------------------------------
414 # Security
415 # ---------------------------------------------------------------------------
416
417
418 class TestSecurity:
419 def test_null_byte_in_path_rejected(self, tmp_path: pathlib.Path) -> None:
420 repo = _make_repo(tmp_path)
421 result = _ca(repo, "tracks/\x00malicious.mid")
422 assert result.exit_code == ExitCode.USER_ERROR
423 assert "null byte" in result.stderr.lower()
424
425 def test_ansi_in_path_stripped_text(self, tmp_path: pathlib.Path) -> None:
426 repo = _make_repo(tmp_path)
427 result = _ca(repo, "\x1b[31mmalicious\x1b[0m.py")
428 assert "\x1b" not in result.output
429
430 def test_ansi_in_path_pattern_stripped_text(self, tmp_path: pathlib.Path) -> None:
431 repo = _make_repo(tmp_path)
432 _write_attrs(repo, """
433 [[rules]]
434 path = "\\u001b[31m*\\u001b[0m"
435 dimension = "*"
436 strategy = "ours"
437 """)
438 result = _ca(repo, "tracks/drums.mid")
439 assert "\x1b" not in result.output
440
441 def test_no_traceback_on_invalid_toml(self, tmp_path: pathlib.Path) -> None:
442 repo = _make_repo(tmp_path)
443 (repo / ".museattributes").write_text("[broken toml !!!")
444 result = _ca(repo, "foo.py")
445 assert "Traceback" not in result.output
446
447 def test_invalid_toml_errors(self, tmp_path: pathlib.Path) -> None:
448 repo = _make_repo(tmp_path)
449 (repo / ".museattributes").write_text("[broken toml !!!")
450 result = _ca(repo, "foo.py")
451 assert result.exit_code == ExitCode.INTERNAL_ERROR
452 assert "Traceback" not in result.output
453
454 def test_no_paths_errors_to_stderr(self, tmp_path: pathlib.Path) -> None:
455 repo = _make_repo(tmp_path)
456 result = _ca(repo)
457 assert result.exit_code == ExitCode.USER_ERROR
458 assert "error" in result.stderr.lower()
459
460
461 # ---------------------------------------------------------------------------
462 # duration_ms
463 # ---------------------------------------------------------------------------
464
465
466 class TestElapsed:
467 def test_elapsed_in_default_json(self, tmp_path: pathlib.Path) -> None:
468 repo = _make_repo(tmp_path)
469 data = json.loads(_ca(repo, "--json", "foo.py").output)
470 assert "duration_ms" in data
471 assert isinstance(data["duration_ms"], float)
472 assert data["duration_ms"] >= 0.0
473
474 def test_elapsed_in_rules_only_json(self, tmp_path: pathlib.Path) -> None:
475 repo = _make_repo(tmp_path)
476 _write_attrs(repo, _SIMPLE_ATTRS)
477 data = json.loads(_ca(repo, "--json", "--rules-only").output)
478 assert "duration_ms" in data
479 assert isinstance(data["duration_ms"], float)
480
481 def test_elapsed_in_all_rules_json(self, tmp_path: pathlib.Path) -> None:
482 repo = _make_repo(tmp_path)
483 data = json.loads(_ca(repo, "--json", "--all-rules", "foo.py").output)
484 assert "duration_ms" in data
485 assert isinstance(data["duration_ms"], float)
486
487 def test_elapsed_absent_from_text_output(self, tmp_path: pathlib.Path) -> None:
488 repo = _make_repo(tmp_path)
489 result = _ca(repo, "foo.py")
490 assert "duration_ms" not in result.output
491
492
493 # ---------------------------------------------------------------------------
494 # exit_code
495 # ---------------------------------------------------------------------------
496
497
498 class TestExitCode:
499 def test_exit_code_0_in_default_json(self, tmp_path: pathlib.Path) -> None:
500 repo = _make_repo(tmp_path)
501 data = json.loads(_ca(repo, "--json", "foo.py").output)
502 assert "exit_code" in data
503 assert data["exit_code"] == 0
504
505 def test_exit_code_in_rules_only_json(self, tmp_path: pathlib.Path) -> None:
506 repo = _make_repo(tmp_path)
507 data = json.loads(_ca(repo, "--json", "--rules-only").output)
508 assert "exit_code" in data
509 assert data["exit_code"] == 0
510
511 def test_exit_code_in_all_rules_json(self, tmp_path: pathlib.Path) -> None:
512 repo = _make_repo(tmp_path)
513 data = json.loads(_ca(repo, "--json", "--all-rules", "foo.py").output)
514 assert "exit_code" in data
515 assert data["exit_code"] == 0
516
517
518 # ---------------------------------------------------------------------------
519 # summary block (default mode only)
520 # ---------------------------------------------------------------------------
521
522
523 class TestSummary:
524 def test_summary_present_in_default_json(self, tmp_path: pathlib.Path) -> None:
525 repo = _make_repo(tmp_path)
526 _write_attrs(repo, _SIMPLE_ATTRS)
527 data = json.loads(_ca(repo, "--json", "build/out.bin", "README.md", "main.py").output)
528 assert "summary" in data
529 s = data["summary"]
530 assert s["total"] == 3
531 assert s["matched"] == 2
532 assert s["unmatched"] == 1
533
534 def test_summary_by_strategy(self, tmp_path: pathlib.Path) -> None:
535 repo = _make_repo(tmp_path)
536 _write_attrs(repo, _SIMPLE_ATTRS)
537 data = json.loads(_ca(repo, "--json", "build/out.bin", "README.md", "main.py").output)
538 by = data["summary"]["by_strategy"]
539 assert by.get("ours") == 1
540 assert by.get("union") == 1
541 assert by.get("auto") == 1
542
543 def test_summary_all_unmatched(self, tmp_path: pathlib.Path) -> None:
544 repo = _make_repo(tmp_path)
545 data = json.loads(_ca(repo, "--json", "foo.py", "bar.py").output)
546 s = data["summary"]
547 assert s["total"] == 2
548 assert s["matched"] == 0
549 assert s["unmatched"] == 2
550 assert s["by_strategy"] == {"auto": 2}
551
552 def test_summary_absent_from_rules_only(self, tmp_path: pathlib.Path) -> None:
553 repo = _make_repo(tmp_path)
554 _write_attrs(repo, _SIMPLE_ATTRS)
555 data = json.loads(_ca(repo, "--json", "--rules-only").output)
556 assert "summary" not in data
557
558 def test_summary_absent_from_all_rules(self, tmp_path: pathlib.Path) -> None:
559 repo = _make_repo(tmp_path)
560 data = json.loads(_ca(repo, "--json", "--all-rules", "foo.py").output)
561 assert "summary" not in data
562
563
564 # ---------------------------------------------------------------------------
565 # --unmatched-only
566 # ---------------------------------------------------------------------------
567
568
569 class TestUnmatchedOnly:
570 def test_filters_to_auto_paths(self, tmp_path: pathlib.Path) -> None:
571 repo = _make_repo(tmp_path)
572 _write_attrs(repo, _SIMPLE_ATTRS)
573 data = json.loads(_ca(repo, "--json", "--unmatched-only", "build/out.bin", "main.py").output)
574 assert len(data["results"]) == 1
575 assert data["results"][0]["path"] == "main.py"
576 assert data["results"][0]["strategy"] == "auto"
577
578 def test_empty_when_all_matched(self, tmp_path: pathlib.Path) -> None:
579 repo = _make_repo(tmp_path)
580 _write_attrs(repo, _SIMPLE_ATTRS)
581 data = json.loads(_ca(repo, "--json", "--unmatched-only", "build/out.bin", "README.md").output)
582 assert data["results"] == []
583
584 def test_all_when_none_matched(self, tmp_path: pathlib.Path) -> None:
585 repo = _make_repo(tmp_path)
586 data = json.loads(_ca(repo, "--json", "--unmatched-only", "foo.py", "bar.py").output)
587 assert len(data["results"]) == 2
588
589 def test_summary_reflects_filter(self, tmp_path: pathlib.Path) -> None:
590 """summary.total reflects filtered count, not original count."""
591 repo = _make_repo(tmp_path)
592 _write_attrs(repo, _SIMPLE_ATTRS)
593 data = json.loads(_ca(repo, "--json", "--unmatched-only", "build/out.bin", "main.py").output)
594 assert data["summary"]["total"] == 1
595 assert data["summary"]["unmatched"] == 1
596
597 def test_text_format(self, tmp_path: pathlib.Path) -> None:
598 repo = _make_repo(tmp_path)
599 _write_attrs(repo, _SIMPLE_ATTRS)
600 result = _ca(repo, "--unmatched-only", "build/out.bin", "main.py")
601 assert result.exit_code == 0
602 assert "build/out.bin" not in result.output
603 assert "main.py" in result.output
604
605 def test_incompatible_with_rules_only(self, tmp_path: pathlib.Path) -> None:
606 repo = _make_repo(tmp_path)
607 result = _ca(repo, "--unmatched-only", "--rules-only")
608 assert result.exit_code == ExitCode.USER_ERROR
609
610 def test_stdin_compatible(self, tmp_path: pathlib.Path) -> None:
611 repo = _make_repo(tmp_path)
612 _write_attrs(repo, _SIMPLE_ATTRS)
613 result = _ca(repo, "--json", "--unmatched-only", "--stdin", stdin="build/out.bin\nmain.py\n")
614 data = json.loads(result.output)
615 assert len(data["results"]) == 1
616 assert data["results"][0]["path"] == "main.py"
617
618
619 # ---------------------------------------------------------------------------
620 # Stress
621 # ---------------------------------------------------------------------------
622
623
624 class TestStress:
625 def test_1000_paths(self, tmp_path: pathlib.Path) -> None:
626 repo = _make_repo(tmp_path)
627 _write_attrs(repo, _SIMPLE_ATTRS)
628 paths = [f"build/file_{i:04d}.bin" for i in range(500)]
629 paths += [f"src/file_{i:04d}.py" for i in range(500)]
630 result = _ca(repo, "--json", *paths)
631 assert result.exit_code == 0
632 data = json.loads(result.output)
633 assert len(data["results"]) == 1000
634 ours_count = sum(1 for r in data["results"] if r["strategy"] == "ours")
635 assert ours_count == 500
636
637 def test_50_rule_set(self, tmp_path: pathlib.Path) -> None:
638 repo = _make_repo(tmp_path)
639 rules_toml = "\n".join(
640 f'[[rules]]\npath = "dir_{i}/*"\ndimension = "*"\nstrategy = "ours"\npriority = {50 - i}\n'
641 for i in range(50)
642 )
643 _write_attrs(repo, rules_toml)
644 data = json.loads(_ca(repo, "--json", "dir_0/file.py", "dir_49/file.py", "other.py").output)
645 assert data["rules_loaded"] == 50
646 assert data["results"][0]["strategy"] == "ours"
647 assert data["results"][1]["strategy"] == "ours"
648 assert data["results"][2]["strategy"] == "auto"
649
650 def test_200_sequential_runs(self, tmp_path: pathlib.Path) -> None:
651 repo = _make_repo(tmp_path)
652 _write_attrs(repo, _SIMPLE_ATTRS)
653 for i in range(200):
654 result = _ca(repo, "--json", "build/out.bin")
655 assert result.exit_code == 0, f"failed at iteration {i}"
656 data = json.loads(result.output)
657 assert data["results"][0]["strategy"] == "ours"
658
659 def test_stdin_1000_paths(self, tmp_path: pathlib.Path) -> None:
660 repo = _make_repo(tmp_path)
661 _write_attrs(repo, _SIMPLE_ATTRS)
662 stdin_input = "\n".join(f"build/file_{i}.bin" for i in range(1000)) + "\n"
663 result = _ca(repo, "--json", "--stdin", stdin=stdin_input)
664 assert result.exit_code == 0
665 data = json.loads(result.output)
666 assert len(data["results"]) == 1000
667 assert all(r["strategy"] == "ours" for r in data["results"])
668
669
670 # ---------------------------------------------------------------------------
671 # Flag tests
672 # ---------------------------------------------------------------------------
673
674
675 import argparse as _argparse
676
677
678 class TestRegisterFlags:
679 def _parse(self, *args: str) -> _argparse.Namespace:
680 from muse.cli.commands.check_attr import register
681 p = _argparse.ArgumentParser()
682 sub = p.add_subparsers()
683 register(sub)
684 return p.parse_args(["check-attr", *args])
685
686 def test_default_json_out_is_false(self) -> None:
687 ns = self._parse("foo.py")
688 assert ns.json_out is False
689
690 def test_json_flag_sets_json_out(self) -> None:
691 ns = self._parse("--json", "foo.py")
692 assert ns.json_out is True
693
694 def test_j_shorthand_sets_json_out(self) -> None:
695 ns = self._parse("-j", "foo.py")
696 assert ns.json_out is True
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago