gabriel / muse public
test_harmony_cli.py python
1,171 lines 42.5 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Tests for ``muse harmony`` CLI — Phase 2.
2
3 Coverage tiers
4 --------------
5 I Unit — TypedDict field presence; subcommand registration
6 II Integration — every subcommand success path (text + JSON)
7 III Integration — every subcommand error path (bad args, not-found)
8 IV End-to-end — full lifecycle through the CLI layer
9 V Data integrity— JSON round-trips; all fields always present
10 VI Security — path-traversal IDs rejected; invalid hex rejected
11 VII Performance — each subcommand completes within 300 ms
12 """
13 from __future__ import annotations
14
15 import hashlib
16 import json
17 import pathlib
18 import time
19 import typing
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner
24
25 runner = CliRunner()
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31
32 def _fake_id(seed: str) -> str:
33 return hashlib.sha256(seed.encode()).hexdigest()
34
35
36 # ---------------------------------------------------------------------------
37 # Fixtures
38 # ---------------------------------------------------------------------------
39
40
41 @pytest.fixture()
42 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
43 """Minimal Muse repo — .muse/config.toml present so require_repo() succeeds."""
44 muse_dir = tmp_path / ".muse"
45 muse_dir.mkdir()
46 (muse_dir / "config.toml").write_text('[repo]\nname = "test"\nid = "abc123"\n')
47 monkeypatch.chdir(tmp_path)
48 return tmp_path
49
50
51 def _record(
52 repo: pathlib.Path,
53 *,
54 path: str = "track.mid",
55 domain: str = "midi",
56 conflict_type: str = "content",
57 ours: str = "ours",
58 theirs: str = "theirs",
59 ) -> str:
60 """Invoke ``muse harmony record`` and return the pattern_id."""
61 r = runner.invoke(None, [
62 "harmony", "record",
63 "--path", path,
64 "--domain", domain,
65 "--conflict-type", conflict_type,
66 "--ours-id", _fake_id(ours),
67 "--theirs-id", _fake_id(theirs),
68 "--json",
69 ])
70 assert r.exit_code == 0, f"record failed: {r.output}"
71 return json.loads(r.output)["pattern_id"]
72
73
74 def _resolve(
75 repo: pathlib.Path,
76 pattern_id: str,
77 *,
78 strategy: str = "manual",
79 confidence: str = "0.9",
80 outcome: str = "outcome",
81 rationale: str = "test",
82 agent_id: str | None = None,
83 ) -> str:
84 """Invoke ``muse harmony resolve`` and return the resolution_id."""
85 args = [
86 "harmony", "resolve",
87 "--pattern-id", pattern_id,
88 "--strategy", strategy,
89 "--outcome-blob", _fake_id(outcome),
90 "--confidence", confidence,
91 "--rationale", rationale,
92 "--json",
93 ]
94 if agent_id:
95 args += ["--agent-id", agent_id]
96 r = runner.invoke(None, args)
97 assert r.exit_code == 0, f"resolve failed: {r.output}"
98 return json.loads(r.output)["resolution_id"]
99
100
101 # ===========================================================================
102 # Tier I — Unit: TypedDict schemas and subcommand registration
103 # ===========================================================================
104
105
106 class TestTypedDictSchemas:
107 """I: All TypedDict output schemas declare expected keys."""
108
109 def _hints(self, name: str) -> dict[str, typing.Any]:
110 import muse.cli.commands.harmony as h
111 td = getattr(h, name)
112 return typing.get_type_hints(td)
113
114 def test_record_json_has_pattern_id(self) -> None:
115 assert "pattern_id" in self._hints("_HarmonyRecordJson")
116
117 def test_record_json_has_already_existed(self) -> None:
118 assert "already_existed" in self._hints("_HarmonyRecordJson")
119
120 def test_list_json_has_total(self) -> None:
121 assert "total" in self._hints("_HarmonyListJson")
122
123 def test_list_json_has_patterns(self) -> None:
124 assert "patterns" in self._hints("_HarmonyListJson")
125
126 def test_show_json_has_pattern(self) -> None:
127 assert "pattern" in self._hints("_HarmonyShowJson")
128
129 def test_show_json_has_resolutions(self) -> None:
130 assert "resolutions" in self._hints("_HarmonyShowJson")
131
132 def test_resolve_json_has_resolution_id(self) -> None:
133 assert "resolution_id" in self._hints("_HarmonyResolveJson")
134
135 def test_resolve_json_has_pattern_id(self) -> None:
136 assert "pattern_id" in self._hints("_HarmonyResolveJson")
137
138 def test_resolve_json_has_already_existed(self) -> None:
139 assert "already_existed" in self._hints("_HarmonyResolveJson")
140
141 def test_best_json_has_pattern_id(self) -> None:
142 assert "pattern_id" in self._hints("_HarmonyBestJson")
143
144 def test_best_json_has_resolution(self) -> None:
145 assert "resolution" in self._hints("_HarmonyBestJson")
146
147 def test_forget_json_has_pattern_id(self) -> None:
148 assert "pattern_id" in self._hints("_HarmonyForgetJson")
149
150 def test_forget_json_has_removed(self) -> None:
151 assert "removed" in self._hints("_HarmonyForgetJson")
152
153 def test_scalar_json_has_removed(self) -> None:
154 assert "removed" in self._hints("_HarmonyScalarJson")
155
156 def test_policy_add_json_has_policy_id(self) -> None:
157 assert "policy_id" in self._hints("_HarmonyPolicyAddJson")
158
159 def test_policy_list_json_has_total(self) -> None:
160 assert "total" in self._hints("_HarmonyPolicyListJson")
161
162 def test_policy_list_json_has_policies(self) -> None:
163 assert "policies" in self._hints("_HarmonyPolicyListJson")
164
165 def test_policy_remove_json_has_policy_id(self) -> None:
166 assert "policy_id" in self._hints("_HarmonyPolicyRemoveJson")
167
168 def test_policy_remove_json_has_removed(self) -> None:
169 assert "removed" in self._hints("_HarmonyPolicyRemoveJson")
170
171 def test_audit_json_has_total(self) -> None:
172 assert "total" in self._hints("_HarmonyAuditJson")
173
174 def test_audit_json_has_entries(self) -> None:
175 assert "entries" in self._hints("_HarmonyAuditJson")
176
177
178 class TestRegistration:
179 """I: harmony is registered in the CLI and subcommands are reachable."""
180
181 def test_harmony_help_exits_0(self, repo: pathlib.Path) -> None:
182 r = runner.invoke(None, ["harmony", "--help"])
183 assert r.exit_code == 0
184
185 def test_harmony_record_help(self, repo: pathlib.Path) -> None:
186 r = runner.invoke(None, ["harmony", "record", "--help"])
187 assert r.exit_code == 0
188
189 def test_harmony_list_help(self, repo: pathlib.Path) -> None:
190 r = runner.invoke(None, ["harmony", "list", "--help"])
191 assert r.exit_code == 0
192
193 def test_harmony_show_help(self, repo: pathlib.Path) -> None:
194 r = runner.invoke(None, ["harmony", "show", "--help"])
195 assert r.exit_code == 0
196
197 def test_harmony_resolve_help(self, repo: pathlib.Path) -> None:
198 r = runner.invoke(None, ["harmony", "resolve", "--help"])
199 assert r.exit_code == 0
200
201 def test_harmony_best_help(self, repo: pathlib.Path) -> None:
202 r = runner.invoke(None, ["harmony", "best", "--help"])
203 assert r.exit_code == 0
204
205 def test_harmony_forget_help(self, repo: pathlib.Path) -> None:
206 r = runner.invoke(None, ["harmony", "forget", "--help"])
207 assert r.exit_code == 0
208
209 def test_harmony_clear_help(self, repo: pathlib.Path) -> None:
210 r = runner.invoke(None, ["harmony", "clear", "--help"])
211 assert r.exit_code == 0
212
213 def test_harmony_gc_help(self, repo: pathlib.Path) -> None:
214 r = runner.invoke(None, ["harmony", "gc", "--help"])
215 assert r.exit_code == 0
216
217 def test_harmony_policy_add_help(self, repo: pathlib.Path) -> None:
218 r = runner.invoke(None, ["harmony", "policy-add", "--help"])
219 assert r.exit_code == 0
220
221 def test_harmony_policy_list_help(self, repo: pathlib.Path) -> None:
222 r = runner.invoke(None, ["harmony", "policy-list", "--help"])
223 assert r.exit_code == 0
224
225 def test_harmony_policy_remove_help(self, repo: pathlib.Path) -> None:
226 r = runner.invoke(None, ["harmony", "policy-remove", "--help"])
227 assert r.exit_code == 0
228
229 def test_harmony_audit_help(self, repo: pathlib.Path) -> None:
230 r = runner.invoke(None, ["harmony", "audit", "--help"])
231 assert r.exit_code == 0
232
233
234 # ===========================================================================
235 # Tier II — Integration: success paths
236 # ===========================================================================
237
238
239 class TestRecordSuccess:
240 """II: muse harmony record — success paths."""
241
242 def test_record_json_returns_pattern_id(self, repo: pathlib.Path) -> None:
243 r = runner.invoke(None, [
244 "harmony", "record",
245 "--path", "track.mid",
246 "--domain", "midi",
247 "--conflict-type", "content",
248 "--ours-id", _fake_id("ours"),
249 "--theirs-id", _fake_id("theirs"),
250 "--json",
251 ])
252 assert r.exit_code == 0
253 data = json.loads(r.output)
254 assert len(data["pattern_id"]) == 64
255 assert data["already_existed"] is False
256
257 def test_record_idempotent_sets_already_existed(self, repo: pathlib.Path) -> None:
258 args = [
259 "harmony", "record",
260 "--path", "track.mid",
261 "--domain", "midi",
262 "--conflict-type", "content",
263 "--ours-id", _fake_id("ours"),
264 "--theirs-id", _fake_id("theirs"),
265 "--json",
266 ]
267 r1 = runner.invoke(None, args)
268 r2 = runner.invoke(None, args)
269 assert r1.exit_code == 0
270 assert r2.exit_code == 0
271 d1 = json.loads(r1.output)
272 d2 = json.loads(r2.output)
273 assert d1["pattern_id"] == d2["pattern_id"]
274 assert d2["already_existed"] is True
275
276 def test_record_text_output(self, repo: pathlib.Path) -> None:
277 r = runner.invoke(None, [
278 "harmony", "record",
279 "--path", "bass.mid",
280 "--domain", "midi",
281 "--conflict-type", "structural",
282 "--ours-id", _fake_id("o2"),
283 "--theirs-id", _fake_id("t2"),
284 ])
285 assert r.exit_code == 0
286 assert "bass.mid" in r.output
287
288 def test_record_with_semantic_fingerprint(self, repo: pathlib.Path) -> None:
289 r = runner.invoke(None, [
290 "harmony", "record",
291 "--path", "piano.mid",
292 "--domain", "midi",
293 "--conflict-type", "content",
294 "--ours-id", _fake_id("ours"),
295 "--theirs-id", _fake_id("theirs"),
296 "--semantic-fingerprint", _fake_id("custom-semantic"),
297 "--json",
298 ])
299 assert r.exit_code == 0
300 data = json.loads(r.output)
301 assert len(data["pattern_id"]) == 64
302
303 def test_record_with_description(self, repo: pathlib.Path) -> None:
304 r = runner.invoke(None, [
305 "harmony", "record",
306 "--path", "lead.mid",
307 "--domain", "midi",
308 "--conflict-type", "content",
309 "--ours-id", _fake_id("ours"),
310 "--theirs-id", _fake_id("theirs"),
311 "--description", '{"bar": 4, "key": "Gmaj"}',
312 "--json",
313 ])
314 assert r.exit_code == 0
315
316
317 class TestListSuccess:
318 """II: muse harmony list — success paths."""
319
320 def test_list_empty_json(self, repo: pathlib.Path) -> None:
321 r = runner.invoke(None, ["harmony", "list", "--json"])
322 assert r.exit_code == 0
323 data = json.loads(r.output)
324 assert data["total"] == 0
325 assert data["patterns"] == []
326
327 def test_list_shows_recorded_pattern(self, repo: pathlib.Path) -> None:
328 pid = _record(repo)
329 r = runner.invoke(None, ["harmony", "list", "--json"])
330 assert r.exit_code == 0
331 data = json.loads(r.output)
332 assert data["total"] == 1
333 assert data["patterns"][0]["pattern_id"] == pid
334
335 def test_list_pattern_entry_has_required_fields(self, repo: pathlib.Path) -> None:
336 _record(repo)
337 r = runner.invoke(None, ["harmony", "list", "--json"])
338 entry = json.loads(r.output)["patterns"][0]
339 for field in ("pattern_id", "path", "domain", "conflict_type",
340 "resolution_count", "recorded_at", "recorded_by"):
341 assert field in entry, f"missing field: {field}"
342
343 def test_list_resolution_count_increments(self, repo: pathlib.Path) -> None:
344 pid = _record(repo)
345 _resolve(repo, pid)
346 r = runner.invoke(None, ["harmony", "list", "--json"])
347 entry = json.loads(r.output)["patterns"][0]
348 assert entry["resolution_count"] == 1
349
350 def test_list_text_output(self, repo: pathlib.Path) -> None:
351 _record(repo)
352 r = runner.invoke(None, ["harmony", "list"])
353 assert r.exit_code == 0
354 assert "track.mid" in r.output
355
356 def test_list_filter_by_domain(self, repo: pathlib.Path) -> None:
357 _record(repo, path="a.mid", domain="midi")
358 _record(repo, path="b.py", domain="code", ours="oa", theirs="tb")
359 r = runner.invoke(None, ["harmony", "list", "--domain", "midi", "--json"])
360 data = json.loads(r.output)
361 assert data["total"] == 1
362 assert data["patterns"][0]["domain"] == "midi"
363
364 def test_list_filter_by_conflict_type(self, repo: pathlib.Path) -> None:
365 _record(repo, path="a.mid", conflict_type="content")
366 _record(repo, path="b.mid", conflict_type="structural", ours="o2", theirs="t2")
367 r = runner.invoke(None, ["harmony", "list", "--conflict-type", "structural", "--json"])
368 data = json.loads(r.output)
369 assert data["total"] == 1
370 assert data["patterns"][0]["conflict_type"] == "structural"
371
372
373 class TestShowSuccess:
374 """II: muse harmony show — success paths."""
375
376 def test_show_pattern_json(self, repo: pathlib.Path) -> None:
377 pid = _record(repo)
378 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
379 assert r.exit_code == 0
380 data = json.loads(r.output)
381 assert data["pattern"]["pattern_id"] == pid
382 assert data["resolutions"] == []
383
384 def test_show_includes_resolutions(self, repo: pathlib.Path) -> None:
385 pid = _record(repo)
386 rid = _resolve(repo, pid)
387 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
388 data = json.loads(r.output)
389 assert len(data["resolutions"]) == 1
390 assert data["resolutions"][0]["resolution_id"] == rid
391
392 def test_show_resolution_has_required_fields(self, repo: pathlib.Path) -> None:
393 pid = _record(repo)
394 _resolve(repo, pid)
395 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
396 res = json.loads(r.output)["resolutions"][0]
397 for field in ("resolution_id", "strategy", "confidence", "human_verified",
398 "applied_count", "resolved_by", "resolved_at", "rationale"):
399 assert field in res, f"missing field: {field}"
400
401 def test_show_text_output(self, repo: pathlib.Path) -> None:
402 pid = _record(repo)
403 r = runner.invoke(None, ["harmony", "show", pid])
404 assert r.exit_code == 0
405 assert pid[:12] in r.output
406
407
408 class TestResolveSuccess:
409 """II: muse harmony resolve — success paths."""
410
411 def test_resolve_json(self, repo: pathlib.Path) -> None:
412 pid = _record(repo)
413 r = runner.invoke(None, [
414 "harmony", "resolve",
415 "--pattern-id", pid,
416 "--strategy", "manual",
417 "--outcome-blob", _fake_id("outcome"),
418 "--confidence", "0.85",
419 "--rationale", "looks good",
420 "--json",
421 ])
422 assert r.exit_code == 0
423 data = json.loads(r.output)
424 assert len(data["resolution_id"]) == 64
425 assert data["pattern_id"] == pid
426 assert data["already_existed"] is False
427
428 def test_resolve_idempotent(self, repo: pathlib.Path) -> None:
429 pid = _record(repo)
430 args = [
431 "harmony", "resolve",
432 "--pattern-id", pid,
433 "--strategy", "manual",
434 "--outcome-blob", _fake_id("outcome"),
435 "--confidence", "0.9",
436 "--json",
437 ]
438 r1 = runner.invoke(None, args)
439 r2 = runner.invoke(None, args)
440 assert r1.exit_code == 0
441 assert r2.exit_code == 0
442 d1, d2 = json.loads(r1.output), json.loads(r2.output)
443 assert d1["resolution_id"] == d2["resolution_id"]
444 assert d2["already_existed"] is True
445
446 def test_resolve_with_agent_provenance(self, repo: pathlib.Path) -> None:
447 pid = _record(repo)
448 r = runner.invoke(None, [
449 "harmony", "resolve",
450 "--pattern-id", pid,
451 "--strategy", "exact-replay",
452 "--outcome-blob", _fake_id("out"),
453 "--confidence", "1.0",
454 "--agent-id", "claude-code",
455 "--model-id", "claude-sonnet-4-6",
456 "--json",
457 ])
458 assert r.exit_code == 0
459 data = json.loads(r.output)
460 assert len(data["resolution_id"]) == 64
461
462 def test_resolve_human_verified(self, repo: pathlib.Path) -> None:
463 pid = _record(repo)
464 r = runner.invoke(None, [
465 "harmony", "resolve",
466 "--pattern-id", pid,
467 "--strategy", "manual",
468 "--outcome-blob", _fake_id("out"),
469 "--confidence", "0.95",
470 "--human-verified",
471 "--json",
472 ])
473 assert r.exit_code == 0
474
475 def test_resolve_text_output(self, repo: pathlib.Path) -> None:
476 pid = _record(repo)
477 r = runner.invoke(None, [
478 "harmony", "resolve",
479 "--pattern-id", pid,
480 "--strategy", "manual",
481 "--outcome-blob", _fake_id("out"),
482 "--confidence", "0.8",
483 ])
484 assert r.exit_code == 0
485 assert pid[:12] in r.output
486
487
488 class TestBestSuccess:
489 """II: muse harmony best — success paths."""
490
491 def test_best_returns_null_when_no_resolution(self, repo: pathlib.Path) -> None:
492 pid = _record(repo)
493 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
494 assert r.exit_code == 0
495 data = json.loads(r.output)
496 assert data["pattern_id"] == pid
497 assert data["resolution"] is None
498
499 def test_best_returns_highest_quality(self, repo: pathlib.Path) -> None:
500 pid = _record(repo)
501 _resolve(repo, pid, confidence="0.5", outcome="low")
502 _resolve(repo, pid, confidence="0.9", outcome="high")
503 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
504 data = json.loads(r.output)
505 assert data["resolution"] is not None
506 assert data["resolution"]["confidence"] == pytest.approx(0.9)
507
508 def test_best_text_output(self, repo: pathlib.Path) -> None:
509 pid = _record(repo)
510 _resolve(repo, pid)
511 r = runner.invoke(None, ["harmony", "best", pid])
512 assert r.exit_code == 0
513 assert pid[:12] in r.output
514
515
516 class TestForgetSuccess:
517 """II: muse harmony forget — success paths."""
518
519 def test_forget_existing_pattern(self, repo: pathlib.Path) -> None:
520 pid = _record(repo)
521 r = runner.invoke(None, ["harmony", "forget", pid, "--json"])
522 assert r.exit_code == 0
523 data = json.loads(r.output)
524 assert data["pattern_id"] == pid
525 assert data["removed"] is True
526
527 def test_forget_nonexistent_returns_false(self, repo: pathlib.Path) -> None:
528 r = runner.invoke(None, ["harmony", "forget", "a" * 64, "--json"])
529 assert r.exit_code == 0
530 data = json.loads(r.output)
531 assert data["removed"] is False
532
533 def test_forget_text_output(self, repo: pathlib.Path) -> None:
534 pid = _record(repo)
535 r = runner.invoke(None, ["harmony", "forget", pid])
536 assert r.exit_code == 0
537 assert pid[:12] in r.output
538
539
540 class TestClearSuccess:
541 """II: muse harmony clear — success paths."""
542
543 def test_clear_empty(self, repo: pathlib.Path) -> None:
544 r = runner.invoke(None, ["harmony", "clear", "--yes", "--json"])
545 assert r.exit_code == 0
546 assert json.loads(r.output)["removed"] == 0
547
548 def test_clear_removes_all(self, repo: pathlib.Path) -> None:
549 for i in range(3):
550 _record(repo, path=f"f{i}.mid", ours=f"o{i}", theirs=f"t{i}")
551 r = runner.invoke(None, ["harmony", "clear", "--yes", "--json"])
552 assert r.exit_code == 0
553 assert json.loads(r.output)["removed"] == 3
554
555 def test_clear_text_output(self, repo: pathlib.Path) -> None:
556 _record(repo)
557 r = runner.invoke(None, ["harmony", "clear", "--yes"])
558 assert r.exit_code == 0
559 assert "1" in r.output
560
561
562 class TestGcSuccess:
563 """II: muse harmony gc — success paths."""
564
565 def test_gc_empty_store(self, repo: pathlib.Path) -> None:
566 r = runner.invoke(None, ["harmony", "gc", "--json"])
567 assert r.exit_code == 0
568 data = json.loads(r.output)
569 assert data["removed"] == 0
570 assert "age_days" in data
571
572 def test_gc_removes_stale_unresolved(self, repo: pathlib.Path) -> None:
573 # Record a pattern and manually backdate it
574 pid = _record(repo)
575 import muse.core.harmony as hm
576 meta_p = hm.patterns_dir(pathlib.Path(".")) / pid / "pattern.json"
577 pattern_data = json.loads(meta_p.read_text())
578 pattern_data["recorded_at"] = "2020-01-01T00:00:00+00:00"
579 meta_p.write_text(json.dumps(pattern_data))
580
581 r = runner.invoke(None, ["harmony", "gc", "--age", "1", "--json"])
582 assert r.exit_code == 0
583 assert json.loads(r.output)["removed"] == 1
584
585 def test_gc_text_output(self, repo: pathlib.Path) -> None:
586 r = runner.invoke(None, ["harmony", "gc"])
587 assert r.exit_code == 0
588
589
590 class TestPolicyAddSuccess:
591 """II: muse harmony policy-add — success paths."""
592
593 def test_policy_add_json(self, repo: pathlib.Path) -> None:
594 r = runner.invoke(None, [
595 "harmony", "policy-add",
596 "--policy-id", "prefer-ours",
597 "--description", "Always prefer ours for midi",
598 "--scope", "repo",
599 "--action", "prefer-ours",
600 "--json",
601 ])
602 assert r.exit_code == 0
603 data = json.loads(r.output)
604 assert data["policy_id"] == "prefer-ours"
605 assert data["action"] == "prefer-ours"
606 assert data["scope"] == "repo"
607
608 def test_policy_add_with_condition(self, repo: pathlib.Path) -> None:
609 r = runner.invoke(None, [
610 "harmony", "policy-add",
611 "--policy-id", "midi-content",
612 "--description", "Midi content policy",
613 "--scope", "domain",
614 "--action", "prefer-ours",
615 "--conflict-type", "content",
616 "--domain", "midi",
617 "--path-pattern", "*.mid",
618 "--confidence", "0.95",
619 "--json",
620 ])
621 assert r.exit_code == 0
622 data = json.loads(r.output)
623 assert data["policy_id"] == "midi-content"
624
625 def test_policy_add_text_output(self, repo: pathlib.Path) -> None:
626 r = runner.invoke(None, [
627 "harmony", "policy-add",
628 "--policy-id", "my-policy",
629 "--description", "Test",
630 "--scope", "workspace",
631 "--action", "escalate",
632 ])
633 assert r.exit_code == 0
634 assert "my-policy" in r.output
635
636
637 class TestPolicyListSuccess:
638 """II: muse harmony policy-list — success paths."""
639
640 def test_policy_list_empty(self, repo: pathlib.Path) -> None:
641 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
642 assert r.exit_code == 0
643 data = json.loads(r.output)
644 assert data["total"] == 0
645 assert data["policies"] == []
646
647 def test_policy_list_shows_added(self, repo: pathlib.Path) -> None:
648 runner.invoke(None, [
649 "harmony", "policy-add",
650 "--policy-id", "p1",
651 "--description", "d",
652 "--scope", "repo",
653 "--action", "prefer-ours",
654 ])
655 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
656 data = json.loads(r.output)
657 assert data["total"] == 1
658 assert data["policies"][0]["policy_id"] == "p1"
659
660 def test_policy_list_entry_has_required_fields(self, repo: pathlib.Path) -> None:
661 runner.invoke(None, [
662 "harmony", "policy-add",
663 "--policy-id", "p2",
664 "--description", "desc",
665 "--scope", "repo",
666 "--action", "prefer-ours",
667 ])
668 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
669 entry = json.loads(r.output)["policies"][0]
670 for field in ("policy_id", "description", "scope", "action", "confidence",
671 "conflict_type", "domain", "path_pattern", "created_at", "created_by"):
672 assert field in entry, f"missing field: {field}"
673
674 def test_policy_list_scope_sorted(self, repo: pathlib.Path) -> None:
675 for pid, scope in [("f", "file"), ("w", "workspace"), ("d", "domain"), ("r", "repo")]:
676 runner.invoke(None, [
677 "harmony", "policy-add",
678 "--policy-id", pid,
679 "--description", "x",
680 "--scope", scope,
681 "--action", "prefer-ours",
682 ])
683 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
684 scopes = [p["scope"] for p in json.loads(r.output)["policies"]]
685 assert scopes.index("workspace") < scopes.index("repo")
686 assert scopes.index("repo") < scopes.index("domain")
687 assert scopes.index("domain") < scopes.index("file")
688
689
690 class TestPolicyRemoveSuccess:
691 """II: muse harmony policy-remove — success paths."""
692
693 def test_policy_remove_existing(self, repo: pathlib.Path) -> None:
694 runner.invoke(None, [
695 "harmony", "policy-add",
696 "--policy-id", "to-remove",
697 "--description", "x",
698 "--scope", "repo",
699 "--action", "prefer-ours",
700 ])
701 r = runner.invoke(None, ["harmony", "policy-remove", "to-remove", "--json"])
702 assert r.exit_code == 0
703 data = json.loads(r.output)
704 assert data["policy_id"] == "to-remove"
705 assert data["removed"] is True
706
707 def test_policy_remove_nonexistent(self, repo: pathlib.Path) -> None:
708 r = runner.invoke(None, ["harmony", "policy-remove", "no-such-policy", "--json"])
709 assert r.exit_code == 0
710 data = json.loads(r.output)
711 assert data["removed"] is False
712
713
714 class TestAuditSuccess:
715 """II: muse harmony audit — success paths."""
716
717 def test_audit_empty(self, repo: pathlib.Path) -> None:
718 r = runner.invoke(None, ["harmony", "audit", "--json"])
719 assert r.exit_code == 0
720 data = json.loads(r.output)
721 assert data["total"] == 0
722 assert data["entries"] == []
723
724 def test_audit_shows_entries_after_record(self, repo: pathlib.Path) -> None:
725 _record(repo)
726 r = runner.invoke(None, ["harmony", "audit", "--json"])
727 assert r.exit_code == 0
728 data = json.loads(r.output)
729 assert data["total"] >= 1
730
731 def test_audit_entry_has_required_fields(self, repo: pathlib.Path) -> None:
732 _record(repo)
733 r = runner.invoke(None, ["harmony", "audit", "--json"])
734 entry = json.loads(r.output)["entries"][0]
735 for field in ("audit_id", "event_type", "pattern_id", "resolution_id",
736 "policy_id", "acted_by", "occurred_at", "metadata"):
737 assert field in entry, f"missing field: {field}"
738
739 def test_audit_limit(self, repo: pathlib.Path) -> None:
740 for i in range(5):
741 _record(repo, path=f"f{i}.mid", ours=f"o{i}", theirs=f"t{i}")
742 r = runner.invoke(None, ["harmony", "audit", "--limit", "2", "--json"])
743 data = json.loads(r.output)
744 assert len(data["entries"]) <= 2
745
746
747 # ===========================================================================
748 # Tier III — Integration: error paths
749 # ===========================================================================
750
751
752 class TestRecordErrors:
753 """III: muse harmony record — error paths."""
754
755 def test_record_missing_path_exits_nonzero(self, repo: pathlib.Path) -> None:
756 r = runner.invoke(None, [
757 "harmony", "record",
758 "--domain", "midi",
759 "--conflict-type", "content",
760 "--ours-id", _fake_id("o"),
761 "--theirs-id", _fake_id("t"),
762 ])
763 assert r.exit_code != 0
764
765 def test_record_missing_ours_id_exits_nonzero(self, repo: pathlib.Path) -> None:
766 r = runner.invoke(None, [
767 "harmony", "record",
768 "--path", "track.mid",
769 "--domain", "midi",
770 "--conflict-type", "content",
771 "--theirs-id", _fake_id("t"),
772 ])
773 assert r.exit_code != 0
774
775 def test_record_invalid_ours_id_exits_1(self, repo: pathlib.Path) -> None:
776 r = runner.invoke(None, [
777 "harmony", "record",
778 "--path", "track.mid",
779 "--domain", "midi",
780 "--conflict-type", "content",
781 "--ours-id", "not-hex",
782 "--theirs-id", _fake_id("t"),
783 "--json",
784 ])
785 assert r.exit_code == 1
786
787 def test_record_bad_description_json_exits_1(self, repo: pathlib.Path) -> None:
788 r = runner.invoke(None, [
789 "harmony", "record",
790 "--path", "track.mid",
791 "--domain", "midi",
792 "--conflict-type", "content",
793 "--ours-id", _fake_id("o"),
794 "--theirs-id", _fake_id("t"),
795 "--description", "{bad json",
796 "--json",
797 ])
798 assert r.exit_code == 1
799
800
801 class TestResolveErrors:
802 """III: muse harmony resolve — error paths."""
803
804 def test_resolve_missing_pattern_exits_1(self, repo: pathlib.Path) -> None:
805 r = runner.invoke(None, [
806 "harmony", "resolve",
807 "--pattern-id", "a" * 64,
808 "--strategy", "manual",
809 "--outcome-blob", _fake_id("out"),
810 "--confidence", "0.9",
811 "--json",
812 ])
813 assert r.exit_code == 1
814
815 def test_resolve_invalid_pattern_id_exits_1(self, repo: pathlib.Path) -> None:
816 r = runner.invoke(None, [
817 "harmony", "resolve",
818 "--pattern-id", "not-hex",
819 "--strategy", "manual",
820 "--outcome-blob", _fake_id("out"),
821 "--confidence", "0.9",
822 "--json",
823 ])
824 assert r.exit_code == 1
825
826 def test_resolve_confidence_out_of_range_exits_1(self, repo: pathlib.Path) -> None:
827 pid = _record(repo)
828 r = runner.invoke(None, [
829 "harmony", "resolve",
830 "--pattern-id", pid,
831 "--strategy", "manual",
832 "--outcome-blob", _fake_id("out"),
833 "--confidence", "1.5",
834 "--json",
835 ])
836 assert r.exit_code == 1
837
838 def test_resolve_negative_confidence_exits_1(self, repo: pathlib.Path) -> None:
839 pid = _record(repo)
840 r = runner.invoke(None, [
841 "harmony", "resolve",
842 "--pattern-id", pid,
843 "--strategy", "manual",
844 "--outcome-blob", _fake_id("out"),
845 "--confidence", "-0.1",
846 "--json",
847 ])
848 assert r.exit_code == 1
849
850
851 class TestShowErrors:
852 """III: muse harmony show — error paths."""
853
854 def test_show_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
855 r = runner.invoke(None, ["harmony", "show", "a" * 64, "--json"])
856 assert r.exit_code == 1
857
858 def test_show_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
859 r = runner.invoke(None, ["harmony", "show", "bad-id", "--json"])
860 assert r.exit_code == 1
861
862
863 class TestBestErrors:
864 """III: muse harmony best — error paths."""
865
866 def test_best_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
867 r = runner.invoke(None, ["harmony", "best", "bad-id", "--json"])
868 assert r.exit_code == 1
869
870
871 class TestForgetErrors:
872 """III: muse harmony forget — error paths."""
873
874 def test_forget_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
875 r = runner.invoke(None, ["harmony", "forget", "bad-id", "--json"])
876 assert r.exit_code == 1
877
878
879 class TestGcErrors:
880 """III: muse harmony gc — error paths."""
881
882 def test_gc_invalid_age_exits_1(self, repo: pathlib.Path) -> None:
883 r = runner.invoke(None, ["harmony", "gc", "--age", "0", "--json"])
884 assert r.exit_code == 1
885
886 def test_gc_negative_age_exits_1(self, repo: pathlib.Path) -> None:
887 r = runner.invoke(None, ["harmony", "gc", "--age", "-5", "--json"])
888 assert r.exit_code == 1
889
890
891 class TestPolicyErrors:
892 """III: muse harmony policy-add — error paths."""
893
894 def test_policy_add_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
895 r = runner.invoke(None, [
896 "harmony", "policy-add",
897 "--policy-id", "bad/id",
898 "--description", "x",
899 "--scope", "repo",
900 "--action", "prefer-ours",
901 "--json",
902 ])
903 assert r.exit_code == 1
904
905 def test_policy_remove_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
906 r = runner.invoke(None, ["harmony", "policy-remove", "bad/id", "--json"])
907 assert r.exit_code == 1
908
909
910 # ===========================================================================
911 # Tier IV — End-to-end lifecycle
912 # ===========================================================================
913
914
915 class TestEndToEnd:
916 """IV: Full lifecycle via CLI."""
917
918 def test_record_resolve_best_lifecycle(self, repo: pathlib.Path) -> None:
919 # Record
920 pid = _record(repo, path="lifecycle.mid", domain="midi")
921
922 # Resolve
923 rid = _resolve(repo, pid, strategy="manual", confidence="0.88")
924
925 # Best
926 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
927 data = json.loads(r.output)
928 assert data["resolution"]["resolution_id"] == rid
929 assert data["resolution"]["confidence"] == pytest.approx(0.88)
930
931 # Audit has entries
932 ra = runner.invoke(None, ["harmony", "audit", "--json"])
933 assert json.loads(ra.output)["total"] >= 2
934
935 def test_policy_controls_match_then_remove(self, repo: pathlib.Path) -> None:
936 # Add policy
937 runner.invoke(None, [
938 "harmony", "policy-add",
939 "--policy-id", "midi-all",
940 "--description", "prefer ours for all midi",
941 "--scope", "domain",
942 "--action", "prefer-ours",
943 "--domain", "midi",
944 "--json",
945 ])
946
947 # List confirms it's there
948 rl = runner.invoke(None, ["harmony", "policy-list", "--json"])
949 assert json.loads(rl.output)["total"] == 1
950
951 # Remove
952 runner.invoke(None, ["harmony", "policy-remove", "midi-all"])
953
954 # List now empty
955 rl2 = runner.invoke(None, ["harmony", "policy-list", "--json"])
956 assert json.loads(rl2.output)["total"] == 0
957
958 def test_forget_removes_from_list(self, repo: pathlib.Path) -> None:
959 pid = _record(repo)
960 runner.invoke(None, ["harmony", "forget", pid])
961 r = runner.invoke(None, ["harmony", "list", "--json"])
962 assert json.loads(r.output)["total"] == 0
963
964 def test_clear_empties_store(self, repo: pathlib.Path) -> None:
965 for i in range(3):
966 _record(repo, path=f"g{i}.mid", ours=f"go{i}", theirs=f"gt{i}")
967 runner.invoke(None, ["harmony", "clear", "--yes"])
968 r = runner.invoke(None, ["harmony", "list", "--json"])
969 assert json.loads(r.output)["total"] == 0
970
971 def test_gc_does_not_remove_resolved_pattern(self, repo: pathlib.Path) -> None:
972 pid = _record(repo)
973 _resolve(repo, pid)
974
975 # Backdate recorded_at to trigger age threshold
976 import muse.core.harmony as hm
977 meta_p = hm.patterns_dir(pathlib.Path(".")) / pid / "pattern.json"
978 d = json.loads(meta_p.read_text())
979 d["recorded_at"] = "2020-01-01T00:00:00+00:00"
980 meta_p.write_text(json.dumps(d))
981
982 r = runner.invoke(None, ["harmony", "gc", "--age", "1", "--json"])
983 assert json.loads(r.output)["removed"] == 0
984
985 rl = runner.invoke(None, ["harmony", "list", "--json"])
986 assert json.loads(rl.output)["total"] == 1
987
988
989 # ===========================================================================
990 # Tier V — Data integrity
991 # ===========================================================================
992
993
994 class TestDataIntegrity:
995 """V: JSON schemas always fully populated; round-trips correct."""
996
997 def test_list_entry_fields_always_present(self, repo: pathlib.Path) -> None:
998 _record(repo)
999 r = runner.invoke(None, ["harmony", "list", "--json"])
1000 entry = json.loads(r.output)["patterns"][0]
1001 # resolution_count must be 0, not absent
1002 assert entry["resolution_count"] == 0
1003
1004 def test_show_resolutions_field_present_when_empty(self, repo: pathlib.Path) -> None:
1005 pid = _record(repo)
1006 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
1007 data = json.loads(r.output)
1008 assert "resolutions" in data
1009 assert isinstance(data["resolutions"], list)
1010
1011 def test_best_resolution_is_null_not_missing(self, repo: pathlib.Path) -> None:
1012 pid = _record(repo)
1013 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
1014 data = json.loads(r.output)
1015 assert "resolution" in data
1016 assert data["resolution"] is None
1017
1018 def test_gc_json_always_has_age_days(self, repo: pathlib.Path) -> None:
1019 r = runner.invoke(None, ["harmony", "gc", "--json"])
1020 data = json.loads(r.output)
1021 assert "age_days" in data
1022 assert isinstance(data["age_days"], int)
1023
1024 def test_policy_list_null_conditions_present(self, repo: pathlib.Path) -> None:
1025 runner.invoke(None, [
1026 "harmony", "policy-add",
1027 "--policy-id", "no-conds",
1028 "--description", "x",
1029 "--scope", "repo",
1030 "--action", "prefer-ours",
1031 ])
1032 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
1033 entry = json.loads(r.output)["policies"][0]
1034 assert entry["conflict_type"] is None
1035 assert entry["domain"] is None
1036 assert entry["path_pattern"] is None
1037
1038 def test_resolve_confidence_round_trip(self, repo: pathlib.Path) -> None:
1039 pid = _record(repo)
1040 _resolve(repo, pid, confidence="0.73")
1041 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
1042 conf = json.loads(r.output)["resolution"]["confidence"]
1043 assert abs(conf - 0.73) < 0.01
1044
1045
1046 # ===========================================================================
1047 # Tier VI — Security
1048 # ===========================================================================
1049
1050
1051 class TestSecurity:
1052 """VI: path-traversal IDs and crafted inputs are rejected."""
1053
1054 def test_record_path_traversal_ours_id_rejected(self, repo: pathlib.Path) -> None:
1055 r = runner.invoke(None, [
1056 "harmony", "record",
1057 "--path", "track.mid",
1058 "--domain", "midi",
1059 "--conflict-type", "content",
1060 "--ours-id", "../../../etc/passwd",
1061 "--theirs-id", _fake_id("t"),
1062 "--json",
1063 ])
1064 assert r.exit_code == 1
1065
1066 def test_show_path_traversal_rejected(self, repo: pathlib.Path) -> None:
1067 r = runner.invoke(None, ["harmony", "show", "../../evil", "--json"])
1068 assert r.exit_code == 1
1069
1070 def test_forget_path_traversal_rejected(self, repo: pathlib.Path) -> None:
1071 r = runner.invoke(None, ["harmony", "forget", "../../evil", "--json"])
1072 assert r.exit_code == 1
1073
1074 def test_best_path_traversal_rejected(self, repo: pathlib.Path) -> None:
1075 r = runner.invoke(None, ["harmony", "best", "../../evil", "--json"])
1076 assert r.exit_code == 1
1077
1078 def test_policy_add_slash_in_id_rejected(self, repo: pathlib.Path) -> None:
1079 r = runner.invoke(None, [
1080 "harmony", "policy-add",
1081 "--policy-id", "evil/policy",
1082 "--description", "x",
1083 "--scope", "repo",
1084 "--action", "prefer-ours",
1085 "--json",
1086 ])
1087 assert r.exit_code == 1
1088
1089 def test_policy_remove_slash_in_id_rejected(self, repo: pathlib.Path) -> None:
1090 r = runner.invoke(None, ["harmony", "policy-remove", "../etc/passwd", "--json"])
1091 assert r.exit_code == 1
1092
1093 def test_resolve_path_traversal_pattern_id_rejected(self, repo: pathlib.Path) -> None:
1094 r = runner.invoke(None, [
1095 "harmony", "resolve",
1096 "--pattern-id", "../../evil",
1097 "--strategy", "manual",
1098 "--outcome-blob", _fake_id("out"),
1099 "--confidence", "0.9",
1100 "--json",
1101 ])
1102 assert r.exit_code == 1
1103
1104
1105 # ===========================================================================
1106 # Tier VII — Performance
1107 # ===========================================================================
1108
1109
1110 class TestPerformance:
1111 """VII: each subcommand completes within 300 ms (after warm-up)."""
1112
1113 def test_record_under_300ms(self, repo: pathlib.Path) -> None:
1114 start = time.monotonic()
1115 runner.invoke(None, [
1116 "harmony", "record",
1117 "--path", "perf.mid",
1118 "--domain", "midi",
1119 "--conflict-type", "content",
1120 "--ours-id", _fake_id("po"),
1121 "--theirs-id", _fake_id("pt"),
1122 ])
1123 elapsed = (time.monotonic() - start) * 1000
1124 assert elapsed < 300, f"record took {elapsed:.0f}ms"
1125
1126 def test_list_under_300ms(self, repo: pathlib.Path) -> None:
1127 _record(repo)
1128 start = time.monotonic()
1129 runner.invoke(None, ["harmony", "list", "--json"])
1130 elapsed = (time.monotonic() - start) * 1000
1131 assert elapsed < 300, f"list took {elapsed:.0f}ms"
1132
1133 def test_show_under_300ms(self, repo: pathlib.Path) -> None:
1134 pid = _record(repo)
1135 start = time.monotonic()
1136 runner.invoke(None, ["harmony", "show", pid, "--json"])
1137 elapsed = (time.monotonic() - start) * 1000
1138 assert elapsed < 300, f"show took {elapsed:.0f}ms"
1139
1140 def test_resolve_under_300ms(self, repo: pathlib.Path) -> None:
1141 pid = _record(repo)
1142 start = time.monotonic()
1143 runner.invoke(None, [
1144 "harmony", "resolve",
1145 "--pattern-id", pid,
1146 "--strategy", "manual",
1147 "--outcome-blob", _fake_id("po"),
1148 "--confidence", "0.9",
1149 ])
1150 elapsed = (time.monotonic() - start) * 1000
1151 assert elapsed < 300, f"resolve took {elapsed:.0f}ms"
1152
1153 def test_policy_operations_under_300ms(self, repo: pathlib.Path) -> None:
1154 start = time.monotonic()
1155 runner.invoke(None, [
1156 "harmony", "policy-add",
1157 "--policy-id", "perf-policy",
1158 "--description", "x",
1159 "--scope", "repo",
1160 "--action", "prefer-ours",
1161 ])
1162 runner.invoke(None, ["harmony", "policy-list", "--json"])
1163 runner.invoke(None, ["harmony", "policy-remove", "perf-policy"])
1164 elapsed = (time.monotonic() - start) * 1000
1165 assert elapsed < 300, f"policy ops took {elapsed:.0f}ms"
1166
1167 def test_gc_under_300ms(self, repo: pathlib.Path) -> None:
1168 start = time.monotonic()
1169 runner.invoke(None, ["harmony", "gc", "--json"])
1170 elapsed = (time.monotonic() - start) * 1000
1171 assert elapsed < 300, f"gc took {elapsed:.0f}ms"
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago