gabriel / muse public
test_harmony_integration.py python
564 lines 20.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for harmony.auto_apply and harmony.record_resolutions.
2
3 Exercises the high-level integration helpers that sit between the merge and
4 commit commands and the harmony store. These are the functions that actually
5 wire conflict fingerprinting → pattern storage → resolution replay.
6
7 Bugs documented here:
8
9 Bug 1 — symbol-level conflict paths (e.g. "config.py::SomeSymbol"):
10 The file portion must be extracted for manifest lookups while the full
11 address is stored as the ConflictPattern path.
12
13 Bug 2 — record_resolutions not idempotent:
14 Two calls with the same outcome_blob must produce only one resolution.
15
16 Bug 3 — MERGE_STATE original_conflict_paths:
17 ``muse checkout --ours/--theirs`` clears conflict_paths from MERGE_STATE
18 as each is resolved. By commit time conflict_paths is empty, so
19 record_resolutions is called with [] and nothing is ever recorded.
20 MERGE_STATE must preserve the original conflict list so commit can record.
21 """
22 from __future__ import annotations
23
24 import pathlib
25 import tempfile
26
27 import pytest
28
29 import muse.core.harmony as h
30 from muse.core.harmony import (
31 auto_apply,
32 blob_fingerprint,
33 compute_pattern_id,
34 compute_semantic_fingerprint,
35 list_patterns,
36 list_resolutions,
37 record_resolutions,
38 )
39 from muse.core.object_store import write_object
40 from muse.core._types import Manifest, blob_id, long_id
41
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47
48 def _fake_object_id(content: bytes) -> str:
49 """Return a canonical sha256:-prefixed object ID for content."""
50 return blob_id(content)
51
52
53 def _write_fake_object(root: pathlib.Path, content: bytes) -> str:
54 """Write content to the object store and return its object ID."""
55 oid = _fake_object_id(content)
56 write_object(root, oid, content)
57 return oid
58
59
60 class _FakePlugin:
61 """Minimal MuseDomainPlugin — no HarmonyPlugin sub-protocol."""
62 name = "test"
63 def schema(self): return {}
64
65
66 @pytest.fixture()
67 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
68 muse_dir = tmp_path / ".muse"
69 muse_dir.mkdir()
70 return tmp_path
71
72
73 # ---------------------------------------------------------------------------
74 # compute_semantic_fingerprint
75 # ---------------------------------------------------------------------------
76
77
78 class TestComputeSemanticFingerprint:
79 def test_no_plugin_returns_blob_fingerprint(self, repo: pathlib.Path) -> None:
80 ours = long_id("a" * 64)
81 theirs = long_id("b" * 64)
82 plugin = _FakePlugin()
83 result = compute_semantic_fingerprint("config.py", ours, theirs, plugin, repo)
84 assert result == blob_fingerprint(ours, theirs)
85
86 def test_commutative(self, repo: pathlib.Path) -> None:
87 ours = long_id("a" * 64)
88 theirs = long_id("b" * 64)
89 plugin = _FakePlugin()
90 r1 = compute_semantic_fingerprint("config.py", ours, theirs, plugin, repo)
91 r2 = compute_semantic_fingerprint("config.py", theirs, ours, plugin, repo)
92 assert r1 == r2
93
94 def test_different_paths_different_fingerprints(self, repo: pathlib.Path) -> None:
95 ours = long_id("a" * 64)
96 theirs = long_id("b" * 64)
97 plugin = _FakePlugin()
98 # blob_fingerprint is path-independent; semantic is too by default
99 # but compute_pattern_id incorporates path — verified separately
100 r1 = compute_semantic_fingerprint("a.py", ours, theirs, plugin, repo)
101 r2 = compute_semantic_fingerprint("b.py", ours, theirs, plugin, repo)
102 # Default (no HarmonyPlugin) → same blob_fp regardless of path
103 assert r1 == r2
104
105
106 # ---------------------------------------------------------------------------
107 # record_resolutions — file-level paths
108 # ---------------------------------------------------------------------------
109
110
111 class TestRecordResolutionsFilePaths:
112 """record_resolutions with plain file paths (no :: separator)."""
113
114 def test_records_pattern_and_resolution(self, repo: pathlib.Path) -> None:
115 ours_content = b"version = 1"
116 theirs_content = b"version = 2"
117 resolved_content = b"version = 3"
118
119 ours_id = _write_fake_object(repo, ours_content)
120 theirs_id = _write_fake_object(repo, theirs_content)
121 resolution_id = _write_fake_object(repo, resolved_content)
122
123 ours_manifest: Manifest = {"config.py": ours_id}
124 theirs_manifest: Manifest = {"config.py": theirs_id}
125 new_manifest: Manifest = {"config.py": resolution_id}
126
127 plugin = _FakePlugin()
128 saved = record_resolutions(
129 repo,
130 ["config.py"],
131 ours_manifest,
132 theirs_manifest,
133 new_manifest,
134 "code",
135 plugin,
136 )
137
138 assert saved == ["config.py"]
139 patterns = list_patterns(repo)
140 assert len(patterns) == 1
141 assert patterns[0].path == "config.py"
142 assert patterns[0].domain == "code"
143
144 resolutions = list_resolutions(repo, patterns[0].pattern_id)
145 assert len(resolutions) == 1
146 assert resolutions[0].outcome_blob == resolution_id
147 assert resolutions[0].human_verified is True
148 assert resolutions[0].confidence == 1.0
149
150 def test_skips_path_not_in_manifests(self, repo: pathlib.Path) -> None:
151 plugin = _FakePlugin()
152 saved = record_resolutions(
153 repo,
154 ["missing.py"],
155 {},
156 {},
157 {},
158 "code",
159 plugin,
160 )
161 assert saved == []
162 assert list_patterns(repo) == []
163
164 def test_idempotent_second_call(self, repo: pathlib.Path) -> None:
165 ours_id = _write_fake_object(repo, b"a")
166 theirs_id = _write_fake_object(repo, b"b")
167 resolution_id = _write_fake_object(repo, b"c")
168
169 ours_m: Manifest = {"f.py": ours_id}
170 theirs_m: Manifest = {"f.py": theirs_id}
171 new_m: Manifest = {"f.py": resolution_id}
172 plugin = _FakePlugin()
173
174 record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin)
175 record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin)
176
177 assert len(list_patterns(repo)) == 1
178 assert len(list_resolutions(repo, list_patterns(repo)[0].pattern_id)) == 1
179
180
181 # ---------------------------------------------------------------------------
182 # BUG: record_resolutions — symbol-level paths ("file.py::Symbol")
183 # ---------------------------------------------------------------------------
184
185
186 class TestRecordResolutionsSymbolPaths:
187 """record_resolutions with symbol-level conflict paths.
188
189 Conflict paths from the code-domain merge engine are symbol addresses of
190 the form "config.py::MAX_CONNECTIONS". Manifests are keyed by file path.
191 The function must extract the file portion for manifest lookups while
192 storing the full symbol address in the ConflictPattern.
193 """
194
195 def test_symbol_path_records_pattern(self, repo: pathlib.Path) -> None:
196 ours_id = _write_fake_object(repo, b"MAX_CONNECTIONS = 10")
197 theirs_id = _write_fake_object(repo, b"MAX_CONNECTIONS = 25")
198 resolution_id = _write_fake_object(repo, b"MAX_CONNECTIONS = 50")
199
200 # Manifests are keyed by FILE path
201 ours_manifest: Manifest = {"config.py": ours_id}
202 theirs_manifest: Manifest = {"config.py": theirs_id}
203 new_manifest: Manifest = {"config.py": resolution_id}
204
205 plugin = _FakePlugin()
206 saved = record_resolutions(
207 repo,
208 ["config.py::MAX_CONNECTIONS"], # symbol-level conflict path
209 ours_manifest,
210 theirs_manifest,
211 new_manifest,
212 "code",
213 plugin,
214 )
215
216 assert saved == ["config.py::MAX_CONNECTIONS"], (
217 "record_resolutions silently skipped a symbol-level conflict path — "
218 "it must extract 'config.py' from 'config.py::MAX_CONNECTIONS' "
219 "for manifest lookups"
220 )
221
222 patterns = list_patterns(repo)
223 assert len(patterns) == 1, "Expected exactly one pattern recorded"
224 # The full symbol address should be stored as the path
225 assert patterns[0].path == "config.py::MAX_CONNECTIONS"
226
227 resolutions = list_resolutions(repo, patterns[0].pattern_id)
228 assert len(resolutions) == 1
229 assert resolutions[0].outcome_blob == resolution_id
230
231 def test_multiple_symbol_paths_same_file(self, repo: pathlib.Path) -> None:
232 """Two conflicting symbols in the same file → two distinct patterns."""
233 file_ours = _write_fake_object(repo, b"file ours")
234 file_theirs = _write_fake_object(repo, b"file theirs")
235 file_resolved = _write_fake_object(repo, b"file resolved")
236
237 ours_m: Manifest = {"app.py": file_ours}
238 theirs_m: Manifest = {"app.py": file_theirs}
239 new_m: Manifest = {"app.py": file_resolved}
240 plugin = _FakePlugin()
241
242 saved = record_resolutions(
243 repo,
244 ["app.py::foo", "app.py::bar"],
245 ours_m,
246 theirs_m,
247 new_m,
248 "code",
249 plugin,
250 )
251
252 assert saved == ["app.py::foo", "app.py::bar"]
253 patterns = list_patterns(repo)
254 assert len(patterns) == 2, (
255 "Each symbol address should produce a distinct pattern "
256 "(pattern_id incorporates path)"
257 )
258 paths = {p.path for p in patterns}
259 assert paths == {"app.py::foo", "app.py::bar"}
260
261 def test_symbol_path_no_file_portion_in_manifest(self, repo: pathlib.Path) -> None:
262 """If the file portion of the symbol path is not in the manifest, skip."""
263 plugin = _FakePlugin()
264 saved = record_resolutions(
265 repo,
266 ["missing.py::SomeSymbol"],
267 {}, # empty manifests
268 {},
269 {},
270 "code",
271 plugin,
272 )
273 assert saved == []
274
275
276 # ---------------------------------------------------------------------------
277 # BUG: auto_apply — symbol-level paths
278 # ---------------------------------------------------------------------------
279
280
281 class TestAutoApplySymbolPaths:
282 """auto_apply must also extract the file portion from symbol paths."""
283
284 def test_auto_apply_with_symbol_path_records_pattern(
285 self, repo: pathlib.Path
286 ) -> None:
287 ours_id = _write_fake_object(repo, b"DEBUG = False")
288 theirs_id = _write_fake_object(repo, b"DEBUG = True")
289
290 ours_m: Manifest = {"settings.py": ours_id}
291 theirs_m: Manifest = {"settings.py": theirs_id}
292 plugin = _FakePlugin()
293
294 resolved, remaining = auto_apply(
295 repo,
296 ["settings.py::DEBUG"], # symbol-level path
297 ours_m,
298 theirs_m,
299 "code",
300 plugin,
301 )
302
303 assert "settings.py::DEBUG" in remaining
304 patterns = list_patterns(repo)
305 assert len(patterns) == 1, (
306 "auto_apply must record the pattern even when no resolution exists yet "
307 "— but it silently skipped the symbol-level path"
308 )
309 assert patterns[0].path == "settings.py::DEBUG"
310
311 def test_auto_apply_replays_symbol_resolution(
312 self, repo: pathlib.Path
313 ) -> None:
314 """After record_resolutions saves a resolution, auto_apply replays it."""
315 ours_id = _write_fake_object(repo, b"TIMEOUT = 30")
316 theirs_id = _write_fake_object(repo, b"TIMEOUT = 60")
317 resolution_content = b"TIMEOUT = 45"
318 resolution_id = _write_fake_object(repo, resolution_content)
319
320 ours_m: Manifest = {"config.py": ours_id}
321 theirs_m: Manifest = {"config.py": theirs_id}
322 new_m: Manifest = {"config.py": resolution_id}
323 plugin = _FakePlugin()
324
325 # Simulate commit recording the resolution
326 saved = record_resolutions(
327 repo,
328 ["config.py::TIMEOUT"],
329 ours_m,
330 theirs_m,
331 new_m,
332 "code",
333 plugin,
334 )
335 assert saved == ["config.py::TIMEOUT"]
336
337 # Now the same conflict recurs — auto_apply should replay it
338 dest = repo / "config.py"
339 resolved, remaining = auto_apply(
340 repo,
341 ["config.py::TIMEOUT"],
342 ours_m,
343 theirs_m,
344 "code",
345 plugin,
346 )
347
348 assert "config.py::TIMEOUT" in resolved, (
349 "auto_apply failed to replay a saved resolution for a symbol-level path"
350 )
351 assert remaining == []
352 assert dest.read_bytes() == resolution_content
353
354 def test_auto_apply_file_path_still_works(
355 self, repo: pathlib.Path
356 ) -> None:
357 """Plain file paths (no ::) still work after the fix."""
358 ours_id = _write_fake_object(repo, b"v1")
359 theirs_id = _write_fake_object(repo, b"v2")
360 resolution_content = b"v3"
361 resolution_id = _write_fake_object(repo, resolution_content)
362
363 ours_m: Manifest = {"README.md": ours_id}
364 theirs_m: Manifest = {"README.md": theirs_id}
365 new_m: Manifest = {"README.md": resolution_id}
366 plugin = _FakePlugin()
367
368 record_resolutions(repo, ["README.md"], ours_m, theirs_m, new_m, "code", plugin)
369
370 dest = repo / "README.md"
371 resolved, remaining = auto_apply(
372 repo, ["README.md"], ours_m, theirs_m, "code", plugin
373 )
374
375 assert "README.md" in resolved
376 assert remaining == []
377 assert dest.read_bytes() == resolution_content
378
379
380 # ---------------------------------------------------------------------------
381 # auto_apply — path traversal guard still applies
382 # ---------------------------------------------------------------------------
383
384
385 class TestAutoApplyPathTraversal:
386 def test_traversal_path_skipped(self, repo: pathlib.Path) -> None:
387 ours_id = _write_fake_object(repo, b"x")
388 theirs_id = _write_fake_object(repo, b"y")
389 ours_m: Manifest = {"../evil.py": ours_id}
390 theirs_m: Manifest = {"../evil.py": theirs_id}
391 plugin = _FakePlugin()
392
393 resolved, remaining = auto_apply(
394 repo, ["../evil.py"], ours_m, theirs_m, "code", plugin
395 )
396 assert resolved == {}
397 assert "../evil.py" in remaining
398
399 def test_symbol_traversal_skipped(self, repo: pathlib.Path) -> None:
400 ours_id = _write_fake_object(repo, b"x")
401 theirs_id = _write_fake_object(repo, b"y")
402 ours_m: Manifest = {"../evil.py": ours_id}
403 theirs_m: Manifest = {"../evil.py": theirs_id}
404 plugin = _FakePlugin()
405
406 resolved, remaining = auto_apply(
407 repo, ["../evil.py::Symbol"], ours_m, theirs_m, "code", plugin
408 )
409 assert resolved == {}
410 assert "../evil.py::Symbol" in remaining
411
412
413 # ---------------------------------------------------------------------------
414 # BUG 3: MERGE_STATE must preserve original_conflict_paths
415 # ---------------------------------------------------------------------------
416
417
418 class TestMergeStateOriginalConflictPaths:
419 """MERGE_STATE.original_conflict_paths must survive checkout --ours/--theirs.
420
421 Workflow:
422 1. muse merge → MERGE_STATE written with conflict_paths=[A, B]
423 2. muse checkout --ours A → MERGE_STATE updated: conflict_paths=[B]
424 3. muse checkout --ours B → MERGE_STATE updated: conflict_paths=[]
425 4. muse commit → reads merge_state; calls record_resolutions(conflict_paths=[])
426 → nothing recorded ← BUG
427
428 Fix: MERGE_STATE preserves original_conflict_paths=[A, B] through all
429 checkout calls. Commit reads original_conflict_paths for record_resolutions.
430 """
431
432 def test_write_merge_state_sets_original_conflict_paths(
433 self, repo: pathlib.Path
434 ) -> None:
435 from muse.core.merge_engine import write_merge_state, read_merge_state
436
437 write_merge_state(
438 repo,
439 base_commit=long_id("0" * 64),
440 ours_commit=long_id("1" * 64),
441 theirs_commit=long_id("2" * 64),
442 conflict_paths=["config.py::MAX_CONNECTIONS", "utils.py::clamp"],
443 )
444
445 state = read_merge_state(repo)
446 assert state is not None
447 assert state.original_conflict_paths == [
448 "config.py::MAX_CONNECTIONS",
449 "utils.py::clamp",
450 ], (
451 "write_merge_state must populate original_conflict_paths "
452 "equal to conflict_paths on first write"
453 )
454
455 def test_original_conflict_paths_preserved_after_partial_resolution(
456 self, repo: pathlib.Path
457 ) -> None:
458 from muse.core.merge_engine import write_merge_state, read_merge_state
459
460 # First write — merge produces two conflicts
461 write_merge_state(
462 repo,
463 base_commit=long_id("0" * 64),
464 ours_commit=long_id("1" * 64),
465 theirs_commit=long_id("2" * 64),
466 conflict_paths=["config.py::A", "config.py::B"],
467 )
468
469 # Second write — checkout --ours resolved A; only B remains
470 write_merge_state(
471 repo,
472 base_commit=long_id("0" * 64),
473 ours_commit=long_id("1" * 64),
474 theirs_commit=long_id("2" * 64),
475 conflict_paths=["config.py::B"],
476 )
477
478 state = read_merge_state(repo)
479 assert state is not None
480 assert state.conflict_paths == ["config.py::B"]
481 assert state.original_conflict_paths == ["config.py::A", "config.py::B"], (
482 "original_conflict_paths must be preserved across writes — "
483 "checkout --ours updates conflict_paths but not original_conflict_paths"
484 )
485
486 def test_original_conflict_paths_preserved_after_all_resolved(
487 self, repo: pathlib.Path
488 ) -> None:
489 from muse.core.merge_engine import write_merge_state, read_merge_state
490
491 write_merge_state(
492 repo,
493 base_commit=long_id("0" * 64),
494 ours_commit=long_id("1" * 64),
495 theirs_commit=long_id("2" * 64),
496 conflict_paths=["config.py::MAX_CONNECTIONS"],
497 )
498 # All resolved via checkout --ours
499 write_merge_state(
500 repo,
501 base_commit=long_id("0" * 64),
502 ours_commit=long_id("1" * 64),
503 theirs_commit=long_id("2" * 64),
504 conflict_paths=[],
505 )
506
507 state = read_merge_state(repo)
508 assert state is not None
509 assert state.conflict_paths == []
510 assert state.original_conflict_paths == ["config.py::MAX_CONNECTIONS"], (
511 "original_conflict_paths must survive even when all conflicts are cleared"
512 )
513
514 def test_commit_uses_original_conflict_paths_for_harmony(
515 self, repo: pathlib.Path
516 ) -> None:
517 """Commit must pass original_conflict_paths to record_resolutions.
518
519 When all conflicts have been resolved via checkout --ours/--theirs,
520 merge_state.conflict_paths is empty. Commit must fall back to
521 merge_state.original_conflict_paths so harmony still learns.
522 """
523 from muse.core.merge_engine import write_merge_state, read_merge_state
524
525 ours_id = _write_fake_object(repo, b"MAX_CONNECTIONS = 50")
526 theirs_id = _write_fake_object(repo, b"MAX_CONNECTIONS = 25")
527 resolution_id = _write_fake_object(repo, b"MAX_CONNECTIONS = 50") # ours wins
528
529 # Simulate: merge wrote state with conflict, then checkout --ours cleared it
530 write_merge_state(
531 repo,
532 base_commit=long_id("0" * 64),
533 ours_commit=long_id("1" * 64),
534 theirs_commit=long_id("2" * 64),
535 conflict_paths=["config.py::MAX_CONNECTIONS"],
536 )
537 write_merge_state(
538 repo,
539 base_commit=long_id("0" * 64),
540 ours_commit=long_id("1" * 64),
541 theirs_commit=long_id("2" * 64),
542 conflict_paths=[], # all resolved
543 )
544
545 state = read_merge_state(repo)
546 assert state is not None
547 assert state.conflict_paths == []
548 assert state.original_conflict_paths == ["config.py::MAX_CONNECTIONS"]
549
550 # The commit should use original_conflict_paths, not conflict_paths
551 ours_m: Manifest = {"config.py": ours_id}
552 theirs_m: Manifest = {"config.py": theirs_id}
553 new_m: Manifest = {"config.py": resolution_id}
554 plugin = _FakePlugin()
555
556 paths_for_harmony = state.original_conflict_paths or state.conflict_paths
557 saved = record_resolutions(
558 repo, paths_for_harmony, ours_m, theirs_m, new_m, "code", plugin
559 )
560
561 assert saved == ["config.py::MAX_CONNECTIONS"], (
562 "commit.py must use merge_state.original_conflict_paths when "
563 "conflict_paths is empty — harmony must learn the resolution"
564 )
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago