gabriel / muse public
test_lineage.py python
579 lines 22.0 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive tests for muse code lineage.
2
3 Test layers
4 -----------
5 Unit
6 ``build_lineage`` exercised directly with synthetic ``CommitRecord`` objects
7 carrying hand-crafted ``structured_delta`` data. No repo, no disk I/O.
8
9 Integration
10 CLI invocations via ``CliRunner`` against a real tmp-path repo with two
11 Python commits (the shared ``code_repo`` fixture).
12
13 Edge-case
14 Empty history, deleted-then-re-created, address without ``::`` guard,
15 unknown branch / ref, ``--filter`` narrowing, ``--since``/``--until``
16 date bounds, ``--count`` output, ``--stability`` output.
17
18 Stress
19 Programmatically generate N commits each carrying an InsertOp, ModifyOp,
20 or DeleteOp and verify ``build_lineage`` produces the expected event count
21 and kind sequence without error.
22 """
23
24 from __future__ import annotations
25
26 import datetime
27 import json
28 import pathlib
29 import textwrap
30
31 import pytest
32
33 from tests.cli_test_helper import CliRunner
34 from muse.cli.commands.lineage import _LineageEvent, _classify_replace, _stability, build_lineage
35 from muse.core.store import CommitRecord
36 from muse.domain import DeleteOp, DomainOp, InsertOp, PatchOp, ReplaceOp
37
38 cli = None # argparse migration — CliRunner ignores this arg
39 runner = CliRunner()
40
41 # ---------------------------------------------------------------------------
42 # Helpers
43 # ---------------------------------------------------------------------------
44
45 _REPO_ID = "test-repo-id"
46 _SEQ = [0]
47
48
49 def _cid(tag: str) -> str:
50 """Return a deterministic 64-char hex content_id from a short tag."""
51 return tag.ljust(64, "0")[:64]
52
53
54 def _ts(offset_days: int = 0) -> datetime.datetime:
55 base = datetime.datetime(2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
56 return base + datetime.timedelta(days=offset_days)
57
58
59 def _commit(
60 *,
61 message: str = "commit",
62 ops: list[DomainOp] | None = None,
63 day: int = 0,
64 commit_id: str | None = None,
65 ) -> CommitRecord:
66 """Build a synthetic CommitRecord with the given symbol-level ops."""
67 _SEQ[0] += 1
68 cid = commit_id or f"c{_SEQ[0]:063d}"
69 return CommitRecord(
70 commit_id=cid,
71 repo_id=_REPO_ID,
72 created_on_branch="main",
73 snapshot_id=f"snap-{cid}",
74 message=message,
75 committed_at=_ts(day),
76 structured_delta={"ops": ops or [], "domain": "code", "summary": message},
77 )
78
79
80 def _insert(address: str, content_id: str) -> InsertOp:
81 return InsertOp(
82 op="insert",
83 address=address,
84 position=None,
85 content_id=_cid(content_id),
86 content_summary=f"function {address.split('::')[-1]}",
87 )
88
89
90 def _delete(address: str, content_id: str) -> DeleteOp:
91 return DeleteOp(
92 op="delete",
93 address=address,
94 position=None,
95 content_id=_cid(content_id),
96 content_summary=f"function {address.split('::')[-1]}",
97 )
98
99
100 def _replace(address: str, old_cid: str, new_cid: str, old_sum: str = "", new_sum: str = "") -> ReplaceOp:
101 return ReplaceOp(
102 op="replace",
103 address=address,
104 position=None,
105 old_content_id=_cid(old_cid),
106 new_content_id=_cid(new_cid),
107 old_summary=old_sum,
108 new_summary=new_sum,
109 )
110
111
112 def _patch(*child_ops: DomainOp, file: str = "billing.py") -> PatchOp:
113 """Wrap symbol-level ops in a PatchOp (as Muse emits for file changes)."""
114 return PatchOp(
115 op="patch",
116 address=file,
117 child_ops=list(child_ops),
118 child_domain="code",
119 child_summary="",
120 )
121
122
123 ADDR = "billing.py::compute_total"
124 OTHER = "billing.py::compute_total_v2"
125 OTHER_FILE = "utils.py::compute_total"
126
127
128 # ---------------------------------------------------------------------------
129 # Unit: _classify_replace
130 # ---------------------------------------------------------------------------
131
132
133 class TestClassifyReplace:
134 def test_signature_change_detected_in_old(self) -> None:
135 assert _classify_replace("signature changed", "") == "signature_change"
136
137 def test_signature_change_detected_in_new(self) -> None:
138 assert _classify_replace("", "new signature") == "signature_change"
139
140 def test_full_rewrite_when_no_signature(self) -> None:
141 assert _classify_replace("impl updated", "impl updated v2") == "full_rewrite"
142
143 def test_empty_summaries_full_rewrite(self) -> None:
144 assert _classify_replace("", "") == "full_rewrite"
145
146
147 # ---------------------------------------------------------------------------
148 # Unit: _stability
149 # ---------------------------------------------------------------------------
150
151
152 class TestStability:
153 def test_no_events(self) -> None:
154 assert _stability([]) == (0, 0)
155
156 def test_all_created(self) -> None:
157 evs = [_LineageEvent("c1", "2026-01-01", "init", "created")]
158 assert _stability(evs) == (0, 1)
159
160 def test_mixed(self) -> None:
161 evs = [
162 _LineageEvent("c1", "2026-01-01", "init", "created"),
163 _LineageEvent("c2", "2026-01-02", "fix", "modified", detail="impl_only"),
164 _LineageEvent("c3", "2026-01-03", "fix2", "modified", detail="full_rewrite"),
165 ]
166 assert _stability(evs) == (2, 3)
167
168
169 # ---------------------------------------------------------------------------
170 # Unit: build_lineage — core event kinds
171 # ---------------------------------------------------------------------------
172
173
174 class TestBuildLineageCreated:
175 def test_no_commits(self) -> None:
176 assert build_lineage(ADDR, []) == []
177
178 def test_no_structured_delta(self) -> None:
179 c = CommitRecord(
180 commit_id="c" * 64,
181 repo_id=_REPO_ID,
182 created_on_branch="main",
183 snapshot_id="snap",
184 message="empty",
185 committed_at=_ts(),
186 structured_delta=None,
187 )
188 assert build_lineage(ADDR, [c]) == []
189
190 def test_single_insert_emits_created(self) -> None:
191 c = _commit(ops=[_insert(ADDR, "aaa")], message="add fn", day=0)
192 events = build_lineage(ADDR, [c])
193 assert len(events) == 1
194 assert events[0].kind == "created"
195 assert events[0].message == "add fn"
196 assert events[0].new_content_id == _cid("aaa")
197
198 def test_insert_inside_patch_op(self) -> None:
199 """flat_symbol_ops must recurse into PatchOp.child_ops."""
200 c = _commit(ops=[_patch(_insert(ADDR, "bbb"))], message="patch add")
201 events = build_lineage(ADDR, [c])
202 assert len(events) == 1
203 assert events[0].kind == "created"
204
205 def test_unrelated_insert_ignored(self) -> None:
206 c = _commit(ops=[_insert("billing.py::other_fn", "ccc")])
207 assert build_lineage(ADDR, [c]) == []
208
209
210 class TestBuildLineageModified:
211 def test_replace_emits_modified(self) -> None:
212 c1 = _commit(ops=[_insert(ADDR, "v1")], day=0)
213 c2 = _commit(ops=[_replace(ADDR, "v1", "v2")], day=1, message="fix")
214 events = build_lineage(ADDR, [c1, c2])
215 kinds = [e.kind for e in events]
216 assert kinds == ["created", "modified"]
217 assert events[1].detail == "full_rewrite"
218 assert events[1].message == "fix"
219
220 def test_replace_with_signature_detail(self) -> None:
221 c1 = _commit(ops=[_insert(ADDR, "v1")], day=0)
222 c2 = _commit(ops=[_replace(ADDR, "v1", "v2", old_sum="signature changed")], day=1)
223 events = build_lineage(ADDR, [c1, c2])
224 assert events[1].kind == "modified"
225 assert events[1].detail == "signature_change"
226
227 def test_multiple_modifications_in_sequence(self) -> None:
228 commits = [
229 _commit(ops=[_insert(ADDR, "v1")], day=0),
230 _commit(ops=[_replace(ADDR, "v1", "v2")], day=1),
231 _commit(ops=[_replace(ADDR, "v2", "v3")], day=2),
232 _commit(ops=[_replace(ADDR, "v3", "v4")], day=3),
233 ]
234 events = build_lineage(ADDR, commits)
235 assert len(events) == 4
236 assert events[0].kind == "created"
237 assert all(e.kind == "modified" for e in events[1:])
238
239
240 class TestBuildLineageDeleted:
241 def test_delete_emits_deleted(self) -> None:
242 c1 = _commit(ops=[_insert(ADDR, "v1")], day=0)
243 c2 = _commit(ops=[_delete(ADDR, "v1")], day=1, message="remove fn")
244 events = build_lineage(ADDR, [c1, c2])
245 assert events[-1].kind == "deleted"
246 assert events[-1].message == "remove fn"
247
248 def test_delete_marks_address_not_live(self) -> None:
249 """After delete, re-inserting the same content should emit 'created', not 'copied_from'."""
250 c1 = _commit(ops=[_insert(ADDR, "v1")], day=0)
251 c2 = _commit(ops=[_delete(ADDR, "v1")], day=1)
252 c3 = _commit(ops=[_insert(ADDR, "v1")], day=2)
253 events = build_lineage(ADDR, [c1, c2, c3])
254 kinds = [e.kind for e in events]
255 assert kinds == ["created", "deleted", "created"]
256
257
258 class TestBuildLineageRenamedMoved:
259 def test_rename_within_same_file(self) -> None:
260 """InsertOp at ADDR + DeleteOp at OTHER (same file, same content_id) → renamed_from."""
261 c1 = _commit(ops=[_insert(OTHER, "v1")], day=0)
262 c2 = _commit(ops=[_insert(ADDR, "v1"), _delete(OTHER, "v1")], day=1, message="rename")
263 events = build_lineage(ADDR, [c1, c2])
264 ev = next(e for e in events if e.kind == "renamed_from")
265 assert ev.detail == OTHER
266 assert ev.message == "rename"
267
268 def test_move_across_files(self) -> None:
269 """InsertOp at ADDR + DeleteOp at OTHER_FILE (different file) → moved_from."""
270 c1 = _commit(ops=[_insert(OTHER_FILE, "v1")], day=0)
271 c2 = _commit(ops=[_insert(ADDR, "v1"), _delete(OTHER_FILE, "v1")], day=1, message="move")
272 events = build_lineage(ADDR, [c1, c2])
273 ev = next(e for e in events if e.kind == "moved_from")
274 assert ev.detail == OTHER_FILE
275 assert ev.message == "move"
276
277 def test_rename_file_correctly_classified(self) -> None:
278 """Same file → renamed_from, not moved_from."""
279 c1 = _commit(ops=[_insert(OTHER, "v1")], day=0)
280 c2 = _commit(ops=[_insert(ADDR, "v1"), _delete(OTHER, "v1")], day=1)
281 events = build_lineage(ADDR, [c1, c2])
282 assert any(e.kind == "renamed_from" for e in events)
283 assert not any(e.kind == "moved_from" for e in events)
284
285
286 class TestBuildLineageCopied:
287 def test_copied_from_living_symbol(self) -> None:
288 """Insert at ADDR with content_id already live at OTHER → copied_from."""
289 c1 = _commit(ops=[_insert(OTHER, "v1")], day=0)
290 c2 = _commit(ops=[_insert(ADDR, "v1")], day=1, message="copy fn")
291 events = build_lineage(ADDR, [c1, c2])
292 assert events[0].kind == "copied_from"
293 assert events[0].detail == OTHER
294 assert events[0].message == "copy fn"
295
296 def test_not_copied_when_no_living_symbol(self) -> None:
297 """Insert with unique content_id → created, not copied_from."""
298 c = _commit(ops=[_insert(ADDR, "unique_content")], day=0)
299 events = build_lineage(ADDR, [c])
300 assert events[0].kind == "created"
301
302
303 # ---------------------------------------------------------------------------
304 # Unit: build_lineage — complex lifecycle
305 # ---------------------------------------------------------------------------
306
307
308 class TestBuildLineageLifecycle:
309 def test_full_lifecycle(self) -> None:
310 """create → modify → rename_away → recreate → delete."""
311 # Phase 1: created at ADDR
312 c1 = _commit(ops=[_insert(ADDR, "v1")], day=0, message="create")
313 # Phase 2: modified
314 c2 = _commit(ops=[_replace(ADDR, "v1", "v2")], day=1, message="modify")
315 # Phase 3: renamed away — ADDR is deleted, NEW_ADDR is inserted
316 new_addr = "billing.py::compute_total_renamed"
317 c3 = _commit(ops=[_insert(new_addr, "v2"), _delete(ADDR, "v2")], day=2, message="rename away")
318 # Phase 4: ADDR re-created with fresh content
319 c4 = _commit(ops=[_insert(ADDR, "v3")], day=3, message="recreate")
320 # Phase 5: deleted
321 c5 = _commit(ops=[_delete(ADDR, "v3")], day=4, message="delete")
322
323 events = build_lineage(ADDR, [c1, c2, c3, c4, c5])
324 kinds = [e.kind for e in events]
325 assert kinds == ["created", "modified", "deleted", "created", "deleted"]
326
327 def test_commit_message_propagated(self) -> None:
328 c1 = _commit(ops=[_insert(ADDR, "v1")], message="Initial commit")
329 events = build_lineage(ADDR, [c1])
330 assert events[0].message == "Initial commit"
331
332 def test_to_dict_has_full_commit_id(self) -> None:
333 c1 = _commit(ops=[_insert(ADDR, "v1")], commit_id="a" * 64)
334 events = build_lineage(ADDR, [c1])
335 d = events[0].to_dict()
336 assert d["commit_id"] == "a" * 64 # not truncated
337
338 def test_to_dict_has_message(self) -> None:
339 c1 = _commit(ops=[_insert(ADDR, "v1")], message="My message")
340 events = build_lineage(ADDR, [c1])
341 assert events[0].to_dict()["message"] == "My message"
342
343 def test_commits_without_symbol_ops_skipped(self) -> None:
344 """File-level ops (no '::') must not generate any events."""
345 file_op = ReplaceOp(
346 op="replace",
347 address="billing.py",
348 position=None,
349 old_content_id=_cid("old"),
350 new_content_id=_cid("new"),
351 old_summary="",
352 new_summary="",
353 )
354 c = _commit(ops=[file_op])
355 assert build_lineage(ADDR, [c]) == []
356
357
358 # ---------------------------------------------------------------------------
359 # Unit: build_lineage — date filtering via _gather_commits (tested indirectly
360 # through the CLI --since/--until flags in integration tests below)
361 # ---------------------------------------------------------------------------
362
363
364 # ---------------------------------------------------------------------------
365 # Stress: large commit sequence
366 # ---------------------------------------------------------------------------
367
368
369 class TestBuildLineageStress:
370 def test_many_modifications(self) -> None:
371 """500 sequential modifications produce 501 events without error."""
372 n = 500
373 commits: list[CommitRecord] = [_commit(ops=[_insert(ADDR, "v0")], day=0)]
374 for i in range(1, n + 1):
375 commits.append(_commit(
376 ops=[_replace(ADDR, f"v{i-1}", f"v{i}")],
377 day=i,
378 ))
379 events = build_lineage(ADDR, commits)
380 assert len(events) == n + 1
381 assert events[0].kind == "created"
382 assert all(e.kind == "modified" for e in events[1:])
383
384 def test_many_unrelated_commits_skipped_efficiently(self) -> None:
385 """1000 commits touching only unrelated symbols → 0 events for ADDR."""
386 commits = [
387 _commit(ops=[_insert(f"billing.py::other_{i}", f"uid_{i}")], day=i)
388 for i in range(1000)
389 ]
390 events = build_lineage(ADDR, commits)
391 assert events == []
392
393 def test_interleaved_symbol_and_unrelated_ops(self) -> None:
394 """Mix of target-symbol ops and noise — only target events emitted."""
395 commits: list[CommitRecord] = []
396 for i in range(200):
397 ops: list[DomainOp] = [_insert(f"billing.py::noise_{i}", f"n{i}")]
398 if i == 50:
399 ops.append(_insert(ADDR, "start"))
400 if i == 100:
401 ops.append(_replace(ADDR, "start", "mid"))
402 if i == 150:
403 ops.append(_delete(ADDR, "mid"))
404 commits.append(_commit(ops=ops, day=i))
405
406 events = build_lineage(ADDR, commits)
407 kinds = [e.kind for e in events]
408 assert kinds == ["created", "modified", "deleted"]
409
410 def test_delete_recreate_cycle(self) -> None:
411 """Symbol deleted and recreated 10 times → 10 deletes + 11 creates."""
412 commits: list[CommitRecord] = [_commit(ops=[_insert(ADDR, "v0")], day=0)]
413 for cycle in range(10):
414 base = cycle * 2 + 1
415 commits.append(_commit(ops=[_delete(ADDR, f"v{cycle}")], day=base))
416 commits.append(_commit(ops=[_insert(ADDR, f"v{cycle+1}")], day=base + 1))
417
418 events = build_lineage(ADDR, commits)
419 kinds = [e.kind for e in events]
420 assert kinds.count("created") == 11
421 assert kinds.count("deleted") == 10
422
423
424 # ---------------------------------------------------------------------------
425 # Integration: CLI
426 # ---------------------------------------------------------------------------
427
428
429 @pytest.fixture
430 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
431 monkeypatch.chdir(tmp_path)
432 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
433 r = runner.invoke(cli, ["init", "--domain", "code"])
434 assert r.exit_code == 0, r.output
435 return tmp_path
436
437
438 @pytest.fixture
439 def code_repo(repo: pathlib.Path) -> pathlib.Path:
440 """Two-commit repo: billing.py created then function renamed."""
441 (repo / "billing.py").write_text(textwrap.dedent("""\
442 def compute_total(items):
443 return sum(items)
444
445 def process_order(invoice, items):
446 return compute_total(items)
447 """))
448 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
449 assert r.exit_code == 0, r.output
450
451 (repo / "billing.py").write_text(textwrap.dedent("""\
452 def compute_invoice_total(items):
453 return sum(items)
454
455 def process_order(invoice, items):
456 return compute_invoice_total(items)
457 """))
458 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total"])
459 assert r.exit_code == 0, r.output
460 return repo
461
462
463 class TestLineageCLI:
464 def test_exits_zero_for_existing_symbol(self, code_repo: pathlib.Path) -> None:
465 result = runner.invoke(cli, ["code", "lineage", "billing.py::process_order"])
466 assert result.exit_code == 0, result.output
467
468 def test_json_schema(self, code_repo: pathlib.Path) -> None:
469 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
470 assert result.exit_code == 0, result.output
471 data = json.loads(result.output)
472 assert "events" in data
473 assert "total" in data
474 assert "stability_pct" in data
475 assert "modified_count" in data
476 assert isinstance(data["events"], list)
477 for ev in data["events"]:
478 assert "commit_id" in ev
479 assert "committed_at" in ev
480 assert "event" in ev
481 assert "message" in ev
482 # Full sha256:<64-hex> format — not truncated
483 assert ev["commit_id"].startswith("sha256:")
484 assert len(ev["commit_id"]) == 71
485
486 def test_no_address_separator_rejected(self, code_repo: pathlib.Path) -> None:
487 result = runner.invoke(cli, ["code", "lineage", "billing.py"])
488 assert result.exit_code == 1
489
490 def test_missing_symbol_returns_zero_events(self, code_repo: pathlib.Path) -> None:
491 result = runner.invoke(cli, ["code", "lineage", "billing.py::nonexistent_xyz"])
492 assert result.exit_code == 0
493 assert "no events found" in result.output
494
495 def test_count_only_outputs_integer(self, code_repo: pathlib.Path) -> None:
496 result = runner.invoke(cli, ["code", "lineage", "--count", "billing.py::process_order"])
497 assert result.exit_code == 0
498 assert result.output.strip().isdigit()
499
500 def test_filter_created_subset(self, code_repo: pathlib.Path) -> None:
501 result = runner.invoke(cli, [
502 "code", "lineage", "--filter", "created",
503 "--json", "billing.py::process_order",
504 ])
505 assert result.exit_code == 0
506 data = json.loads(result.output)
507 for ev in data["events"]:
508 assert ev["event"] == "created"
509
510 def test_filter_modified_subset(self, code_repo: pathlib.Path) -> None:
511 result = runner.invoke(cli, [
512 "code", "lineage", "--filter", "modified",
513 "--json", "billing.py::process_order",
514 ])
515 assert result.exit_code == 0
516 data = json.loads(result.output)
517 for ev in data["events"]:
518 assert ev["event"] == "modified"
519
520 def test_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
521 result = runner.invoke(cli, [
522 "code", "lineage", "--since", "2099-01-01",
523 "billing.py::process_order",
524 ])
525 assert result.exit_code == 0
526 assert "no events found" in result.output
527
528 def test_since_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
529 result = runner.invoke(cli, [
530 "code", "lineage", "--since", "not-a-date",
531 "billing.py::process_order",
532 ])
533 assert result.exit_code == 1
534
535 def test_until_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
536 result = runner.invoke(cli, [
537 "code", "lineage", "--until", "99/99/99",
538 "billing.py::process_order",
539 ])
540 assert result.exit_code == 1
541
542 def test_commit_flag_accepted(self, code_repo: pathlib.Path) -> None:
543 result = runner.invoke(cli, [
544 "code", "lineage", "--commit", "HEAD",
545 "billing.py::process_order",
546 ])
547 assert result.exit_code == 0
548
549 def test_stability_flag_present_in_output(self, code_repo: pathlib.Path) -> None:
550 result = runner.invoke(cli, [
551 "code", "lineage", "--stability", "billing.py::process_order",
552 ])
553 assert result.exit_code == 0
554 # Stability line only appears when there are events.
555 # Just check no crash.
556
557 def test_unknown_branch_rejected(self, code_repo: pathlib.Path) -> None:
558 result = runner.invoke(cli, [
559 "code", "lineage", "--branch", "nonexistent-branch-xyz",
560 "billing.py::process_order",
561 ])
562 assert result.exit_code == 1
563
564 def test_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
565 monkeypatch.chdir(tmp_path)
566 result = runner.invoke(cli, ["code", "lineage", "src/a.py::f"])
567 assert result.exit_code != 0
568
569 def test_json_events_have_full_commit_id(self, code_repo: pathlib.Path) -> None:
570 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
571 assert result.exit_code == 0
572 data = json.loads(result.output)
573 for ev in data["events"]:
574 assert ev["commit_id"].startswith("sha256:"), (
575 "commit_id must use sha256: prefix format"
576 )
577 assert len(ev["commit_id"]) == 71, (
578 f"sha256:<64-hex> should be 71 chars, got {len(ev['commit_id'])}"
579 )
File History 3 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 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago