gabriel / muse public

test_knowtation_merger.py file-level

at sha256:6 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 test: validate staging push and MP workflow for aaronrene · · Jul 7, 2026
1 """Tests for ``muse/plugins/knowtation/merger.py`` β€” Phase 2.4.
2
3 Covers all seven tiers required by Rule #0:
4
5 1. **Unit** β€” per-dimension merge primitives (title clean / conflict,
6 scalar clean / conflict, set union / deletion, section
7 add / remove / move, line diff3, marker defang).
8 2. **Integration** β€” 20 hand-crafted three-way merge fixtures spanning the
9 common cases (clean tags merge, title change on one
10 side, scalar conflict, section add on one side, body
11 edit on different sections, body edit on same section).
12 3. **End-to-end** β€” ``merge_vault`` over a synthetic 20-note vault returns
13 the expected merged manifest and conflict list.
14 4. **Stress** β€” 100-branch fan-in: 100 notes against a shared base,
15 each touching a different section β†’ all clean.
16 5. **Data-integrity** β€” round-trip after a clean merge:
17 ``apply(diff(M, M), M) == M`` and ``M`` is byte-stable
18 through ``canonicalize``.
19 6. **Performance** β€” merge of two 500-section notes completes in < 2 s.
20 7. **Security** β€” YAML injection neutralised by ``safe_load``;
21 conflict-marker injection in input is defanged;
22 NUL bytes survive end-to-end; oversize inputs raise
23 the documented ``ValueError``.
24
25 Conventions
26 -----------
27 * Notes are constructed via :func:`_note` and passed through
28 :func:`canonicalize` so byte-for-byte assertions are stable.
29 * No real Muse repository is required; ``merge_vault`` reads blobs from a
30 flat ``.knowtation_blobs/<sha>`` directory inside the test ``tmp_path``.
31 """
32
33 from __future__ import annotations
34
35 import hashlib
36 import json
37 import pathlib
38 import time
39 from typing import Any
40
41 import pytest
42 import yaml
43
44 from muse.core.attributes import AttributeRule
45 from muse.plugins.knowtation.differ import (
46 apply,
47 canonicalize,
48 diff_notes,
49 )
50 from muse.plugins.knowtation.merger import (
51 _CONFLICT_DIR,
52 _MARK_OURS,
53 _MARK_SEP,
54 _MARK_THEIRS,
55 _MARKER_DEFANG_PREFIX,
56 _conflict_fingerprint,
57 _is_summary_section,
58 _merge_frontmatter,
59 _merge_notes_internal,
60 _merge_scalar_field,
61 _merge_section_body,
62 _merge_set_field,
63 _merge_title,
64 _NoteConflict,
65 _three_way_merge_lines,
66 merge_notes,
67 merge_vault,
68 )
69 from muse.plugins.knowtation.differ import _split_sections_typed
70
71
72 # ─────────────────────────────────────────────────────────────────────────────
73 # Construction helpers
74 # ─────────────────────────────────────────────────────────────────────────────
75
76
77 def _note(frontmatter: dict[str, Any] | None, body: str = "") -> bytes:
78 """Build canonical-form note bytes.
79
80 Args:
81 frontmatter: Frontmatter mapping (``None`` for no frontmatter).
82 body: Body text following the frontmatter.
83
84 Returns:
85 Canonical-form note bytes.
86 """
87 if not frontmatter:
88 return canonicalize(body.encode("utf-8"))
89 yaml_text = yaml.safe_dump(
90 frontmatter,
91 sort_keys=False,
92 default_flow_style=False,
93 allow_unicode=True,
94 width=10000,
95 )
96 raw = f"---\n{yaml_text}---\n{body}".encode("utf-8")
97 return canonicalize(raw)
98
99
100 def _hash(b: bytes) -> str:
101 return hashlib.sha256(b).hexdigest()
102
103
104 def _store_blob(root: pathlib.Path, content: bytes) -> str:
105 """Store *content* in the test blob store and return its SHA-256 hex."""
106 h = _hash(content)
107 blob_dir = root / ".knowtation_blobs"
108 blob_dir.mkdir(parents=True, exist_ok=True)
109 (blob_dir / h).write_bytes(content)
110 return h
111
112
113 # =============================================================================
114 # Tier 1 β€” Unit tests
115 # =============================================================================
116
117
118 class TestMergeTitle:
119 """``_merge_title`` β€” Dimension 1."""
120
121 def test_both_unchanged(self) -> None:
122 result, conflict = _merge_title("Hello", "Hello", "Hello")
123 assert result == "Hello"
124 assert conflict is None
125
126 def test_only_ours_changed(self) -> None:
127 result, conflict = _merge_title("New", "Old", "Old")
128 assert result == "New"
129 assert conflict is None
130
131 def test_only_theirs_changed(self) -> None:
132 result, conflict = _merge_title("Old", "New", "Old")
133 assert result == "New"
134 assert conflict is None
135
136 def test_both_changed_to_same_value(self) -> None:
137 result, conflict = _merge_title("Same", "Same", "Old")
138 assert result == "Same"
139 assert conflict is None
140
141 def test_both_changed_differently(self) -> None:
142 result, conflict = _merge_title("OursTitle", "TheirsTitle", "BaseTitle")
143 assert isinstance(result, str)
144 assert _MARK_OURS in result
145 assert _MARK_THEIRS in result
146 assert "OursTitle" in result
147 assert "TheirsTitle" in result
148 assert conflict is not None
149 assert conflict.dimension == "title"
150
151 def test_neither_set(self) -> None:
152 result, conflict = _merge_title(None, None, None)
153 assert result is None
154 assert conflict is None
155
156
157 class TestMergeScalarField:
158 """``_merge_scalar_field`` β€” Dimension 2."""
159
160 def test_consensus(self) -> None:
161 val, conflict, present = _merge_scalar_field("v", "v", "v")
162 assert (val, conflict, present) == ("v", False, True)
163
164 def test_only_ours_changed(self) -> None:
165 val, conflict, present = _merge_scalar_field("new", "old", "old")
166 assert val == "new"
167 assert conflict is False
168 assert present is True
169
170 def test_only_theirs_changed(self) -> None:
171 val, conflict, present = _merge_scalar_field("old", "new", "old")
172 assert val == "new"
173 assert conflict is False
174
175 def test_both_changed_differently_ours_wins(self) -> None:
176 val, conflict, present = _merge_scalar_field("X", "Y", "B")
177 assert val == "X"
178 assert conflict is True
179 assert present is True
180
181 def test_both_deleted(self) -> None:
182 val, conflict, present = _merge_scalar_field(None, None, "B")
183 assert val is None
184 assert conflict is False
185 assert present is False
186
187 def test_added_by_one_side(self) -> None:
188 val, conflict, present = _merge_scalar_field("v", None, None)
189 assert val == "v"
190 assert present is True
191
192
193 class TestMergeSetField:
194 """``_merge_set_field`` β€” Dimension 3."""
195
196 def test_clean_union(self) -> None:
197 result = _merge_set_field(["a", "b"], ["b", "c"], ["b"])
198 assert result == ["a", "b", "c"]
199
200 def test_consensus_delete(self) -> None:
201 result = _merge_set_field(["a"], ["a"], ["a", "b"])
202 assert result == ["a"]
203
204 def test_one_side_deletes_other_keeps(self) -> None:
205 result = _merge_set_field(["a"], ["a", "b"], ["a", "b"])
206 assert result == ["a", "b"]
207
208 def test_added_by_both_independently(self) -> None:
209 result = _merge_set_field(["x"], ["y"], [])
210 assert result == ["x", "y"]
211
212 def test_empty_sides(self) -> None:
213 assert _merge_set_field(None, None, None) == []
214 assert _merge_set_field([], [], []) == []
215
216 def test_dedup_and_sort(self) -> None:
217 result = _merge_set_field(["b", "a"], ["a", "c"], [])
218 assert result == ["a", "b", "c"]
219
220
221 class TestThreeWayMergeLines:
222 """``_three_way_merge_lines`` β€” diff3 line algorithm."""
223
224 def test_no_changes(self) -> None:
225 merged, n = _three_way_merge_lines(
226 ["a\n", "b\n"], ["a\n", "b\n"], ["a\n", "b\n"]
227 )
228 assert merged == ["a\n", "b\n"]
229 assert n == 0
230
231 def test_only_ours_changed(self) -> None:
232 merged, n = _three_way_merge_lines(
233 ["a\n", "b\n"], ["a\n", "X\n"], ["a\n", "b\n"]
234 )
235 assert merged == ["a\n", "X\n"]
236 assert n == 0
237
238 def test_only_theirs_changed(self) -> None:
239 merged, n = _three_way_merge_lines(
240 ["a\n", "b\n"], ["a\n", "b\n"], ["a\n", "Y\n"]
241 )
242 assert merged == ["a\n", "Y\n"]
243 assert n == 0
244
245 def test_non_overlapping_changes(self) -> None:
246 merged, n = _three_way_merge_lines(
247 ["a\n", "b\n", "c\n"],
248 ["X\n", "b\n", "c\n"],
249 ["a\n", "b\n", "Y\n"],
250 )
251 assert merged == ["X\n", "b\n", "Y\n"]
252 assert n == 0
253
254 def test_overlapping_change_emits_conflict(self) -> None:
255 merged, n = _three_way_merge_lines(
256 ["a\n"], ["X\n"], ["Y\n"]
257 )
258 assert n == 1
259 assert _MARK_OURS in merged
260 assert _MARK_THEIRS in merged
261
262 def test_inbound_marker_defanged(self) -> None:
263 evil = "<<<<<<< inbound\n"
264 merged, n = _three_way_merge_lines(
265 [], [evil], []
266 )
267 assert merged
268 assert merged[0].startswith(_MARKER_DEFANG_PREFIX)
269
270
271 class TestMergeSectionBody:
272 """``_merge_section_body`` β€” text-level wrapper."""
273
274 def test_consensus(self) -> None:
275 text = "## A\nbody\n"
276 merged, n = _merge_section_body(text, text, text)
277 assert merged == text
278 assert n == 0
279
280 def test_only_one_changed(self) -> None:
281 merged, n = _merge_section_body("## A\nb\n", "## A\nNEW\n", "## A\nb\n")
282 assert merged == "## A\nNEW\n"
283 assert n == 0
284
285
286 class TestSummarySectionDetection:
287 def test_summary_title_is_summary(self) -> None:
288 from muse.plugins.knowtation.differ import _Section
289 sec = _Section(sid="section:2:Summary#0", level=2, title="Summary", text="")
290 assert _is_summary_section(sec) is True
291
292 def test_section_marker_glyph_summary(self) -> None:
293 from muse.plugins.knowtation.differ import _Section
294 sec = _Section(sid="x", level=2, title="Β§Summary", text="")
295 assert _is_summary_section(sec) is True
296
297 def test_other_title_not_summary(self) -> None:
298 from muse.plugins.knowtation.differ import _Section
299 sec = _Section(sid="x", level=2, title="Background", text="")
300 assert _is_summary_section(sec) is False
301
302
303 class TestMergeFrontmatterUnit:
304 """``_merge_frontmatter`` end-to-end."""
305
306 def test_clean_merge(self) -> None:
307 o = {"title": "T", "tags": ["a", "b"], "date": "2025-01-01"}
308 t = {"title": "T", "tags": ["b", "c"], "date": "2025-01-01"}
309 b = {"title": "T", "tags": ["b"], "date": "2025-01-01"}
310 merged, conflicts = _merge_frontmatter(o, t, b)
311 assert merged["title"] == "T"
312 assert merged["tags"] == ["a", "b", "c"]
313 assert merged["date"] == "2025-01-01"
314 assert conflicts == []
315
316 def test_scalar_conflict_ours_wins_and_records(self) -> None:
317 o = {"date": "2025-02-01"}
318 t = {"date": "2025-03-01"}
319 b = {"date": "2025-01-01"}
320 merged, conflicts = _merge_frontmatter(o, t, b)
321 assert merged["date"] == "2025-02-01"
322 assert len(conflicts) == 1
323 assert conflicts[0].dimension == "frontmatter"
324 assert "date" in conflicts[0].section
325
326
327 # =============================================================================
328 # Tier 2 β€” Integration: 20 hand-crafted three-way fixtures
329 # =============================================================================
330
331
332 def _fixture_set() -> list[tuple[str, bytes, bytes, bytes, dict[str, Any]]]:
333 """Return 20 (name, ours, theirs, base, expectations) fixtures.
334
335 Each fixture's ``expectations`` dict supports the keys:
336 - ``clean`` (bool, default True)
337 - ``contains_marker`` (bool, default False)
338 - ``expected_tags`` (list[str], optional)
339 - ``expected_title`` (str, optional)
340 """
341
342 def n(fm: dict[str, Any] | None, body: str = "") -> bytes:
343 return _note(fm, body)
344
345 f: list[tuple[str, bytes, bytes, bytes, dict[str, Any]]] = []
346
347 base = n({"title": "T", "tags": ["a"]}, "# T\n\nBody.\n")
348
349 # 1. No-op merge
350 f.append(("noop", base, base, base, {"clean": True}))
351
352 # 2. Clean tag union
353 f.append((
354 "tags-clean-union",
355 n({"title": "T", "tags": ["a", "b"]}, "# T\n\nBody.\n"),
356 n({"title": "T", "tags": ["a", "c"]}, "# T\n\nBody.\n"),
357 base,
358 {"clean": True, "expected_tags": ["a", "b", "c"]},
359 ))
360
361 # 3. Title changed on one side only
362 f.append((
363 "title-only-ours",
364 n({"title": "Better", "tags": ["a"]}, "# T\n\nBody.\n"),
365 base,
366 base,
367 {"clean": True, "expected_title": "Better"},
368 ))
369
370 # 4. Title changed on theirs only
371 f.append((
372 "title-only-theirs",
373 base,
374 n({"title": "Better", "tags": ["a"]}, "# T\n\nBody.\n"),
375 base,
376 {"clean": True, "expected_title": "Better"},
377 ))
378
379 # 5. Title conflict β€” both changed differently
380 f.append((
381 "title-conflict",
382 n({"title": "OursTitle", "tags": ["a"]}, "Body.\n"),
383 n({"title": "TheirsTitle", "tags": ["a"]}, "Body.\n"),
384 base,
385 {"clean": False, "contains_marker": True},
386 ))
387
388 # 6. Title same value on both sides (consensus)
389 f.append((
390 "title-same-consensus",
391 n({"title": "Agreed", "tags": ["a"]}, "Body.\n"),
392 n({"title": "Agreed", "tags": ["a"]}, "Body.\n"),
393 base,
394 {"clean": True, "expected_title": "Agreed"},
395 ))
396
397 # 7. Frontmatter scalar conflict β€” ours wins
398 f.append((
399 "scalar-conflict-ours-wins",
400 n({"title": "T", "date": "2025-02-01"}, "Body.\n"),
401 n({"title": "T", "date": "2025-03-01"}, "Body.\n"),
402 n({"title": "T", "date": "2025-01-01"}, "Body.\n"),
403 {"clean": False},
404 ))
405
406 # 8. Set deletion: both sides remove the same tag
407 f.append((
408 "tag-consensus-delete",
409 n({"title": "T", "tags": ["a"]}, "Body.\n"),
410 n({"title": "T", "tags": ["a"]}, "Body.\n"),
411 n({"title": "T", "tags": ["a", "to-remove"]}, "Body.\n"),
412 {"clean": True, "expected_tags": ["a"]},
413 ))
414
415 # 9. Tag deletion only on ours, theirs unchanged
416 f.append((
417 "tag-deleted-by-ours-only",
418 n({"title": "T", "tags": ["a"]}, "Body.\n"),
419 n({"title": "T", "tags": ["a", "still"]}, "Body.\n"),
420 n({"title": "T", "tags": ["a", "still"]}, "Body.\n"),
421 {"clean": True, "expected_tags": ["a", "still"]},
422 ))
423
424 # 10. New section added by ours only
425 f.append((
426 "section-added-ours",
427 n({"title": "T", "tags": ["a"]}, "# T\n\nBody.\n\n## New\nstuff\n"),
428 base,
429 base,
430 {"clean": True},
431 ))
432
433 # 11. New section added by theirs only
434 f.append((
435 "section-added-theirs",
436 base,
437 n({"title": "T", "tags": ["a"]}, "# T\n\nBody.\n\n## New\nstuff\n"),
438 base,
439 {"clean": True},
440 ))
441
442 # 12. Same new section added by both β†’ consensus, no duplicate
443 same_new = n({"title": "T", "tags": ["a"]}, "# T\n\nBody.\n\n## Both\nadded\n")
444 f.append((
445 "section-added-both-same-content",
446 same_new,
447 same_new,
448 base,
449 {"clean": True},
450 ))
451
452 # 13. Same new section title from both with different content β†’ body merge
453 f.append((
454 "section-added-both-different-content",
455 n({"title": "T", "tags": ["a"]}, "# T\n\nBody.\n\n## Both\nours version\n"),
456 n({"title": "T", "tags": ["a"]}, "# T\n\nBody.\n\n## Both\ntheirs version\n"),
457 base,
458 {"clean": False, "contains_marker": True},
459 ))
460
461 # 14. Section deleted by both
462 base_with_extra = n(
463 {"title": "T", "tags": ["a"]},
464 "# T\n\nBody.\n\n## Drop\nold\n",
465 )
466 f.append((
467 "section-consensus-delete",
468 base,
469 base,
470 base_with_extra,
471 {"clean": True},
472 ))
473
474 # 15. Section bodies edited differently in different sections β€” clean
475 base_two = n(
476 {"title": "T", "tags": ["a"]},
477 "# T\n\n## A\nalpha\n\n## B\nbeta\n",
478 )
479 ours_two = n(
480 {"title": "T", "tags": ["a"]},
481 "# T\n\n## A\nALPHA\n\n## B\nbeta\n",
482 )
483 theirs_two = n(
484 {"title": "T", "tags": ["a"]},
485 "# T\n\n## A\nalpha\n\n## B\nBETA\n",
486 )
487 f.append((
488 "section-edits-different-sections",
489 ours_two,
490 theirs_two,
491 base_two,
492 {"clean": True},
493 ))
494
495 # 16. Same section body edited on both sides differently β†’ conflict
496 base_one = n({"title": "T", "tags": ["a"]}, "# T\n\n## X\nline1\nline2\n")
497 ours_one = n({"title": "T", "tags": ["a"]}, "# T\n\n## X\nline1\nOURS\n")
498 theirs_one = n({"title": "T", "tags": ["a"]}, "# T\n\n## X\nline1\nTHEIRS\n")
499 f.append((
500 "section-same-body-conflict",
501 ours_one,
502 theirs_one,
503 base_one,
504 {"clean": False, "contains_marker": True},
505 ))
506
507 # 17. Same section, same edit on both sides β†’ consensus
508 f.append((
509 "section-same-edit-both",
510 ours_one,
511 ours_one,
512 base_one,
513 {"clean": True},
514 ))
515
516 # 18. Section moved on one side (still a clean merge under our merger)
517 base_mv = n({"title": "T", "tags": ["a"]}, "# T\n\n## A\na\n\n## B\nb\n")
518 ours_mv = n({"title": "T", "tags": ["a"]}, "# T\n\n## B\nb\n\n## A\na\n")
519 f.append((
520 "section-moved-by-ours",
521 ours_mv,
522 base_mv,
523 base_mv,
524 {"clean": True},
525 ))
526
527 # 19. Body line inserted by both at different positions β†’ clean
528 base_lines = n({"title": "T", "tags": ["a"]}, "# T\n\n## X\nA\nB\nC\n")
529 ours_lines = n({"title": "T", "tags": ["a"]}, "# T\n\n## X\nA\nO1\nB\nC\n")
530 theirs_lines = n({"title": "T", "tags": ["a"]}, "# T\n\n## X\nA\nB\nT1\nC\n")
531 f.append((
532 "body-line-insert-different-positions",
533 ours_lines,
534 theirs_lines,
535 base_lines,
536 {"clean": True},
537 ))
538
539 # 20. Frontmatter added on one side only
540 f.append((
541 "frontmatter-added-on-ours",
542 n({"title": "T", "tags": ["a"], "project": "p"}, "Body.\n"),
543 n({"title": "T", "tags": ["a"]}, "Body.\n"),
544 n({"title": "T", "tags": ["a"]}, "Body.\n"),
545 {"clean": True},
546 ))
547
548 return f
549
550
551 @pytest.mark.parametrize("fixture", _fixture_set(), ids=lambda f: f[0])
552 def test_three_way_fixture(fixture: tuple[str, bytes, bytes, bytes, dict[str, Any]]) -> None:
553 name, ours, theirs, base, expect = fixture
554 merged_bytes, conflicts = _merge_notes_internal(ours, theirs, base, path=name)
555 is_clean = len(conflicts) == 0
556
557 if expect.get("clean", True):
558 assert is_clean, f"{name}: expected clean merge, got conflicts={conflicts!r}"
559 else:
560 assert not is_clean, f"{name}: expected conflicts but got clean merge"
561
562 if expect.get("contains_marker"):
563 text = merged_bytes.decode("utf-8", errors="replace")
564 assert _MARK_OURS in text and _MARK_THEIRS in text, name
565
566 if "expected_tags" in expect:
567 text = merged_bytes.decode("utf-8", errors="replace")
568 for tag in expect["expected_tags"]:
569 assert tag in text, f"{name}: missing tag {tag}"
570
571 if "expected_title" in expect:
572 text = merged_bytes.decode("utf-8", errors="replace")
573 assert expect["expected_title"] in text, name
574
575
576 # =============================================================================
577 # Tier 3 β€” End-to-end: synthetic 20-note vault via merge_vault
578 # =============================================================================
579
580
581 def _build_synthetic_vault(
582 root: pathlib.Path, n: int = 20
583 ) -> tuple[dict[str, str], dict[str, str], dict[str, str]]:
584 """Build base / ours / theirs manifests for a synthetic n-note vault.
585
586 For each i in 0..n-1:
587 - Notes 0..n/2: ours adds a tag, theirs unchanged
588 - Notes n/2..n-2: theirs adds a tag, ours unchanged
589 - Note n-1: both modify same scalar field β†’ conflict
590 """
591 base_files: dict[str, str] = {}
592 ours_files: dict[str, str] = {}
593 theirs_files: dict[str, str] = {}
594
595 half = n // 2
596 for i in range(n):
597 path = f"notes/note-{i:03d}.md"
598 base_bytes = _note(
599 {"title": f"Note {i}", "tags": ["base"], "date": "2025-01-01"},
600 f"# Note {i}\n\nOriginal body.\n",
601 )
602 b_hash = _store_blob(root, base_bytes)
603 base_files[path] = b_hash
604
605 if i == n - 1:
606 # Conflict note β€” both change date differently
607 ours_b = _note(
608 {"title": f"Note {i}", "tags": ["base"], "date": "2025-02-01"},
609 f"# Note {i}\n\nOriginal body.\n",
610 )
611 theirs_b = _note(
612 {"title": f"Note {i}", "tags": ["base"], "date": "2025-03-01"},
613 f"# Note {i}\n\nOriginal body.\n",
614 )
615 elif i < half:
616 ours_b = _note(
617 {"title": f"Note {i}", "tags": ["base", "ours-extra"], "date": "2025-01-01"},
618 f"# Note {i}\n\nOriginal body.\n",
619 )
620 theirs_b = base_bytes
621 else:
622 ours_b = base_bytes
623 theirs_b = _note(
624 {"title": f"Note {i}", "tags": ["base", "theirs-extra"], "date": "2025-01-01"},
625 f"# Note {i}\n\nOriginal body.\n",
626 )
627
628 ours_files[path] = _store_blob(root, ours_b)
629 theirs_files[path] = _store_blob(root, theirs_b)
630
631 return base_files, ours_files, theirs_files
632
633
634 def test_merge_vault_end_to_end(tmp_path: pathlib.Path) -> None:
635 base_files, ours_files, theirs_files = _build_synthetic_vault(tmp_path, n=20)
636
637 merged, conflicts = merge_vault(
638 {"files": ours_files, "domain": "knowtation", "directories": []},
639 {"files": theirs_files, "domain": "knowtation", "directories": []},
640 {"files": base_files, "domain": "knowtation", "directories": []},
641 tmp_path,
642 )
643
644 assert set(merged.keys()) == set(base_files.keys()), "all 20 notes should survive"
645 assert len(conflicts) >= 1, "the conflict note should produce a conflict entry"
646 conflict_paths = {c["path"] for c in conflicts}
647 assert "notes/note-019.md" in conflict_paths
648
649
650 def test_merge_vault_only_ours_added(tmp_path: pathlib.Path) -> None:
651 base = _note({"title": "B"}, "B\n")
652 ours_extra = _note({"title": "Extra"}, "Extra body\n")
653 base_h = _store_blob(tmp_path, base)
654 extra_h = _store_blob(tmp_path, ours_extra)
655
656 merged, conflicts = merge_vault(
657 {"files": {"a.md": base_h, "extra.md": extra_h}},
658 {"files": {"a.md": base_h}},
659 {"files": {"a.md": base_h}},
660 tmp_path,
661 )
662 assert merged == {"a.md": base_h, "extra.md": extra_h}
663 assert conflicts == []
664
665
666 def test_merge_vault_consensus_delete(tmp_path: pathlib.Path) -> None:
667 base = _note({"title": "B"}, "Body\n")
668 base_h = _store_blob(tmp_path, base)
669 merged, conflicts = merge_vault(
670 {"files": {}},
671 {"files": {}},
672 {"files": {"deleted.md": base_h}},
673 tmp_path,
674 )
675 assert merged == {}
676 assert conflicts == []
677
678
679 def test_merge_vault_delete_edit_conflict(tmp_path: pathlib.Path) -> None:
680 base = _note({"title": "B"}, "Old\n")
681 modified = _note({"title": "B"}, "New\n")
682 base_h = _store_blob(tmp_path, base)
683 mod_h = _store_blob(tmp_path, modified)
684 merged, conflicts = merge_vault(
685 {"files": {}}, # ours deleted
686 {"files": {"a.md": mod_h}}, # theirs modified
687 {"files": {"a.md": base_h}},
688 tmp_path,
689 )
690 assert "a.md" in merged
691 assert any("delete" in c["description"].lower() for c in conflicts)
692
693
694 def test_merge_vault_summary_conflict_writes_stub(tmp_path: pathlib.Path) -> None:
695 base = _note(
696 {"title": "T"},
697 "# T\n\n## Summary\nold summary line\n",
698 )
699 ours = _note(
700 {"title": "T"},
701 "# T\n\n## Summary\nOURS SUMMARY\n",
702 )
703 theirs = _note(
704 {"title": "T"},
705 "# T\n\n## Summary\nTHEIRS SUMMARY\n",
706 )
707 b_h = _store_blob(tmp_path, base)
708 o_h = _store_blob(tmp_path, ours)
709 t_h = _store_blob(tmp_path, theirs)
710
711 merged, conflicts = merge_vault(
712 {"files": {"a.md": o_h}},
713 {"files": {"a.md": t_h}},
714 {"files": {"a.md": b_h}},
715 tmp_path,
716 )
717 stub_dir = tmp_path / _CONFLICT_DIR
718 assert stub_dir.exists()
719 stubs = list(stub_dir.glob("*.json"))
720 assert len(stubs) == 1
721 payload = json.loads(stubs[0].read_text())
722 assert payload["path"] == "a.md"
723 assert "summary" in payload["section"].lower()
724 assert payload["fingerprint"] == stubs[0].stem
725
726
727 # =============================================================================
728 # Tier 4 β€” Stress: 100-branch fan-in
729 # =============================================================================
730
731
732 def test_stress_100_branches_against_shared_base() -> None:
733 """100 ours/theirs pairs each touching a different section all merge cleanly."""
734 sections_body = "# T\n\n" + "\n".join(
735 f"## Sec{i}\nbase line {i}\n" for i in range(100)
736 )
737 base_bytes = _note({"title": "T"}, sections_body)
738
739 for i in range(100):
740 modified_body = "# T\n\n" + "\n".join(
741 f"## Sec{j}\n" + (f"OURS line {j}\n" if j == i else f"base line {j}\n")
742 for j in range(100)
743 )
744 ours = _note({"title": "T"}, modified_body)
745 merged, conflicts = _merge_notes_internal(
746 ours, base_bytes, base_bytes, path=f"section-{i}"
747 )
748 assert conflicts == []
749 text = merged.decode("utf-8")
750 assert f"OURS line {i}" in text
751
752
753 # =============================================================================
754 # Tier 5 β€” Data integrity: round-trip and idempotency
755 # =============================================================================
756
757
758 class TestDataIntegrity:
759 def test_idempotent_clean_merge(self) -> None:
760 base = _note({"title": "T", "tags": ["a"]}, "# T\n\n## X\nbody\n")
761 ours = _note({"title": "T", "tags": ["a", "b"]}, "# T\n\n## X\nbody\n")
762 theirs = _note({"title": "T", "tags": ["a", "c"]}, "# T\n\n## X\nbody\n")
763 m1 = merge_notes(ours, theirs, base)
764 m2 = merge_notes(ours, theirs, base)
765 assert m1 == m2, "merge_notes is not idempotent"
766 # Re-merging the merged output against itself should yield the same bytes.
767 m3 = merge_notes(m1, m1, m1)
768 assert m3 == m1
769
770 def test_clean_merge_round_trips_through_diff(self) -> None:
771 base = _note({"title": "T", "tags": ["a"]}, "# T\n\n## X\nbody\n")
772 ours = _note({"title": "T", "tags": ["a", "b"]}, "# T\n\n## X\nbody\n")
773 theirs = _note({"title": "T", "tags": ["a", "c"]}, "# T\n\n## X\nbody\n")
774 merged = merge_notes(ours, theirs, base)
775 # Round trip: diff(M, M) is empty; apply(empty, M) == M.
776 delta = diff_notes(merged, merged)
777 assert apply(delta, merged) == merged
778
779 def test_canonicalisation_stable(self) -> None:
780 base = _note({"title": "T", "tags": ["a"]}, "# T\n\nbody\n")
781 ours = _note({"title": "T", "tags": ["a", "b"]}, "# T\n\nbody\n")
782 theirs = _note({"title": "T", "tags": ["a", "c"]}, "# T\n\nbody\n")
783 merged = merge_notes(ours, theirs, base)
784 assert canonicalize(merged) == merged
785
786
787 # =============================================================================
788 # Tier 6 β€” Performance: 500-section notes in < 2 s
789 # =============================================================================
790
791
792 @pytest.mark.perf
793 def test_perf_500_section_merge_under_2s() -> None:
794 sections = [f"## Sec{i}\nline-{i}-A\nline-{i}-B\n" for i in range(500)]
795 base_body = "# T\n\n" + "\n".join(sections)
796 base = _note({"title": "T"}, base_body)
797
798 # Ours edits the first half; theirs edits the second half β€” fully clean.
799 ours_sections = [
800 (s.replace("line-{i}-A".format(i=i), "OURS-A") if i < 250 else s)
801 for i, s in enumerate(sections)
802 ]
803 theirs_sections = [
804 (s.replace("line-{i}-A".format(i=i), "THEIRS-A") if i >= 250 else s)
805 for i, s in enumerate(sections)
806 ]
807 ours = _note({"title": "T"}, "# T\n\n" + "\n".join(ours_sections))
808 theirs = _note({"title": "T"}, "# T\n\n" + "\n".join(theirs_sections))
809
810 start = time.perf_counter()
811 merged = merge_notes(ours, theirs, base)
812 elapsed = time.perf_counter() - start
813 assert elapsed < 2.0, f"500-section merge took {elapsed:.3f}s (> 2s budget)"
814 assert merged
815 assert b"OURS-A" in merged
816 assert b"THEIRS-A" in merged
817
818
819 # =============================================================================
820 # Tier 7 β€” Security
821 # =============================================================================
822
823
824 class TestSecurity:
825 def test_oversize_input_raises(self) -> None:
826 from muse.plugins.knowtation.differ import _MAX_NOTE_BYTES
827
828 oversize = b"x" * (_MAX_NOTE_BYTES + 1)
829 with pytest.raises(ValueError):
830 merge_notes(oversize, b"", b"")
831
832 def test_yaml_safe_load_blocks_python_object_construction(self) -> None:
833 # Crafted frontmatter that, under unsafe yaml, would instantiate
834 # an arbitrary Python object via ``!!python/object``. safe_load
835 # rejects this β€” the merger must process the input without
836 # executing any embedded payload, raising any exception, or
837 # blocking on subprocess calls. We verify by:
838 # 1. Confirming yaml.safe_load itself rejects the payload.
839 # 2. Confirming merge_notes returns deterministically.
840 # 3. Confirming the merged bytes are idempotent under
841 # canonicalize (proving the merger neither parsed nor
842 # executed the unsafe construct).
843 evil_payload = (
844 b"---\n"
845 b"!!python/object/apply:os.system [\"echo MERGER_SHOULD_NOT_EXECUTE\"]\n"
846 b"---\n"
847 b"Body\n"
848 )
849 with pytest.raises(yaml.YAMLError):
850 yaml.safe_load(evil_payload[4:].split(b"\n---")[0].decode())
851 merged = merge_notes(evil_payload, evil_payload, evil_payload)
852 assert isinstance(merged, bytes)
853 assert canonicalize(merged) == merged
854
855 def test_inbound_conflict_marker_defanged(self) -> None:
856 # Attacker stuffs a fake conflict marker into ours's body β€” the
857 # merger must defang it so downstream tooling cannot mistake it
858 # for a real merge marker.
859 base = _note({"title": "T"}, "# T\n\nbody-base\n")
860 ours = _note({"title": "T"}, "# T\n\n<<<<<<< INBOUND\nbody-ours\n=======\nfake\n>>>>>>> END\n")
861 theirs = _note({"title": "T"}, "# T\n\nbody-theirs\n")
862
863 merged = merge_notes(ours, theirs, base)
864 text = merged.decode("utf-8")
865 # Real merge markers may still appear as part of the actual merge,
866 # but the inbound markers must be defanged with the ZWS prefix.
867 assert _MARKER_DEFANG_PREFIX + "<<<<<<< INBOUND" in text
868 assert _MARKER_DEFANG_PREFIX + ">>>>>>> END" in text
869
870 def test_nul_bytes_in_body_safe(self) -> None:
871 body_with_nul = "# T\n\nbefore\x00after\n"
872 ours = _note({"title": "T"}, body_with_nul)
873 theirs = _note({"title": "T"}, "# T\n\nbody\n")
874 base = _note({"title": "T"}, "# T\n\nbody\n")
875 merged = merge_notes(ours, theirs, base)
876 assert merged
877 assert b"\x00" in merged
878
879 def test_binary_content_does_not_crash(self) -> None:
880 binary = bytes(range(256))
881 merged = merge_notes(binary, binary, binary)
882 # safe_load rejects binary content as YAML; merger treats as body.
883 assert isinstance(merged, bytes)
884
885 def test_idempotent_under_marker_injection(self) -> None:
886 # Re-merging a previously-merged conflict result must not introduce
887 # additional spurious markers.
888 base = _note({"title": "T"}, "# T\n\n## X\nA\n")
889 ours = _note({"title": "T"}, "# T\n\n## X\nB\n")
890 theirs = _note({"title": "T"}, "# T\n\n## X\nC\n")
891 merged_once = merge_notes(ours, theirs, base)
892 merged_twice = merge_notes(merged_once, merged_once, merged_once)
893 assert merged_twice == merged_once
894
895
896 # =============================================================================
897 # Helper: fingerprint determinism
898 # =============================================================================
899
900
901 def test_conflict_fingerprint_is_order_independent() -> None:
902 a = _NoteConflict(
903 path="x", section="s1", dimension="sections",
904 description="", ours_hash="o1", theirs_hash="t1", base_hash="b1",
905 )
906 b = _NoteConflict(
907 path="x", section="s2", dimension="sections",
908 description="", ours_hash="o2", theirs_hash="t2", base_hash="b2",
909 )
910 fp1 = _conflict_fingerprint([a, b])
911 fp2 = _conflict_fingerprint([b, a])
912 assert fp1 == fp2
913 assert len(fp1) == 64
914
915
916 # =============================================================================
917 # Helper: attrs-driven file-level strategy override
918 # =============================================================================
919
920
921 def test_attrs_file_level_override_to_union_sorted() -> None:
922 """`union-sorted` strategy at "*" overrides the per-dimension logic."""
923 rule = AttributeRule(
924 path_pattern="notes/**",
925 dimension="*",
926 strategy="union-sorted",
927 comment="",
928 priority=10,
929 source_index=0,
930 )
931 base = _note({"title": "T", "tags": ["a"]}, "Body\n")
932 ours = _note({"title": "T", "tags": ["a", "b"]}, "Body\n")
933 theirs = _note({"title": "T", "tags": ["a", "c"]}, "Body\n")
934 merged = merge_notes(ours, theirs, base, path="notes/foo.md", attrs=[rule])
935 text = merged.decode("utf-8")
936 for tag in ("a", "b", "c"):
937 assert tag in text
938
939
940 def test_attrs_per_dimension_override_for_frontmatter() -> None:
941 """A 'frontmatter' dimension override should swap the dimension result."""
942 rule = AttributeRule(
943 path_pattern="notes/**",
944 dimension="frontmatter",
945 strategy="union-sorted",
946 comment="",
947 priority=10,
948 source_index=0,
949 )
950 base = _note({"title": "T", "tags": ["a"]}, "## X\nbase body\n")
951 ours = _note({"title": "T", "tags": ["a", "b"]}, "## X\nours body\n")
952 theirs = _note({"title": "T", "tags": ["a", "c"]}, "## X\nbase body\n")
953 merged = merge_notes(ours, theirs, base, path="notes/foo.md", attrs=[rule])
954 text = merged.decode("utf-8")
955 for tag in ("a", "b", "c"):
956 assert tag in text
957 # The body merge is untouched by the frontmatter override β†’ ours's edit.
958 assert "ours body" in text