gabriel / muse public
test_lineage_supercharge.py python
610 lines 23.6 KB
Raw
sha256:74b5023693ac2ab80e3b89fddc66e0d60d7d931a1266d3f9294f645c3102fe76 tests/test_lineage_supercharge.py, tests/test_narrative_sup… Human 3 days ago
1 """Supercharge tests for muse code lineage.
2
3 Coverage
4 --------
5 JSON Envelope
6 exit_code — always 0; confirms clean exit for agents
7 duration_ms — non-negative float; timing telemetry
8 -j alias — shorthand for --json (agent-ergonomic)
9 address — symbol address echoed back; agents can verify the target
10
11 Context Fields (agent-verifiable constraints applied to the run)
12 filter — kind_filter echoed in JSON (or None when unset)
13 since — lower date bound echoed as ISO string (or None)
14 until — upper date bound echoed as ISO string (or None)
15
16 Event Detail Fields
17 renamed_from / moved_from / copied_from — detail field preserved in JSON
18 modified — old_content_id / new_content_id present in JSON event
19 deleted — old_content_id present in JSON event
20 created — new_content_id present in JSON event
21
22 Kind Filters (all six enumerated values)
23 created, modified, deleted, renamed_from, moved_from, copied_from
24
25 commit_id Format
26 Real commit_ids use sha256:<64-hex> format (71 chars), not bare hex
27
28 Stability
29 stability_pct computed correctly after kind_filter
30 stability_pct 100 when no modifications
31
32 TypedDict
33 _LineageJson exported — all output keys documented
34 """
35
36 from __future__ import annotations
37
38 import datetime
39 import json
40 import pathlib
41 import textwrap
42
43 import pytest
44
45 from tests.cli_test_helper import CliRunner
46 from muse.cli.commands.lineage import (
47 _LineageEvent,
48 _classify_replace,
49 _stability,
50 build_lineage,
51 )
52 from muse.core.commits import CommitRecord
53 from muse.domain import DeleteOp, DomainOp, InsertOp, ReplaceOp
54
55 cli = None
56 runner = CliRunner()
57
58 # ---------------------------------------------------------------------------
59 # Shared fixtures
60 # ---------------------------------------------------------------------------
61
62 _REPO_ID = "test-repo-id"
63 _SEQ: list[int] = [0]
64
65
66 def _cid(tag: str) -> str:
67 return tag.ljust(64, "0")[:64]
68
69
70 def _ts(offset_days: int = 0) -> datetime.datetime:
71 base = datetime.datetime(2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
72 return base + datetime.timedelta(days=offset_days)
73
74
75 def _commit(
76 *,
77 message: str = "commit",
78 ops: list[DomainOp] | None = None,
79 day: int = 0,
80 commit_id: str | None = None,
81 ) -> CommitRecord:
82 _SEQ[0] += 1
83 cid = commit_id or f"c{_SEQ[0]:063d}"
84 return CommitRecord(
85 commit_id=cid,
86 branch="main",
87 snapshot_id=f"snap-{cid}",
88 message=message,
89 committed_at=_ts(day),
90 structured_delta={"ops": ops or [], "domain": "code", "summary": message},
91 )
92
93
94 def _insert(address: str, content_id: str) -> InsertOp:
95 return InsertOp(
96 op="insert",
97 address=address,
98 position=None,
99 content_id=_cid(content_id),
100 content_summary=f"function {address.split('::')[-1]}",
101 )
102
103
104 def _delete(address: str, content_id: str) -> DeleteOp:
105 return DeleteOp(
106 op="delete",
107 address=address,
108 position=None,
109 content_id=_cid(content_id),
110 content_summary=f"function {address.split('::')[-1]}",
111 )
112
113
114 def _replace(
115 address: str,
116 old_cid: str,
117 new_cid: str,
118 old_sum: str = "",
119 new_sum: str = "",
120 ) -> ReplaceOp:
121 return ReplaceOp(
122 op="replace",
123 address=address,
124 position=None,
125 old_content_id=_cid(old_cid),
126 new_content_id=_cid(new_cid),
127 old_summary=old_sum,
128 new_summary=new_sum,
129 )
130
131
132 @pytest.fixture
133 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
134 monkeypatch.chdir(tmp_path)
135 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
136 r = runner.invoke(cli, ["init", "--domain", "code"])
137 assert r.exit_code == 0, r.output
138 return tmp_path
139
140
141 @pytest.fixture
142 def code_repo(repo: pathlib.Path) -> pathlib.Path:
143 """Repo with a two-commit history: created + renamed."""
144 (repo / "billing.py").write_text(textwrap.dedent("""\
145 def compute_total(items):
146 return sum(items)
147
148 def process_order(invoice, items):
149 return compute_total(items)
150 """))
151 runner.invoke(cli, ["code", "add", "billing.py"])
152 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
153 assert r.exit_code == 0, r.output
154
155 (repo / "billing.py").write_text(textwrap.dedent("""\
156 def compute_invoice_total(items):
157 return sum(items)
158
159 def process_order(invoice, items):
160 return compute_invoice_total(items)
161 """))
162 runner.invoke(cli, ["code", "add", "billing.py"])
163 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total"])
164 assert r.exit_code == 0, r.output
165 return repo
166
167
168 @pytest.fixture
169 def modified_repo(repo: pathlib.Path) -> pathlib.Path:
170 """Repo with three commits: created, modified, modified."""
171 (repo / "billing.py").write_text("def compute_total(items):\n return sum(items)\n")
172 runner.invoke(cli, ["code", "add", "billing.py"])
173 r = runner.invoke(cli, ["commit", "-m", "create"])
174 assert r.exit_code == 0, r.output
175
176 (repo / "billing.py").write_text("def compute_total(items, tax=0):\n return sum(items) + tax\n")
177 runner.invoke(cli, ["code", "add", "billing.py"])
178 r = runner.invoke(cli, ["commit", "-m", "add tax"])
179 assert r.exit_code == 0, r.output
180
181 (repo / "billing.py").write_text("def compute_total(items, tax=0, currency='USD'):\n return sum(items) + tax\n")
182 runner.invoke(cli, ["code", "add", "billing.py"])
183 r = runner.invoke(cli, ["commit", "-m", "add currency"])
184 assert r.exit_code == 0, r.output
185 return repo
186
187
188 # ---------------------------------------------------------------------------
189 # JSON Envelope — exit_code, duration_ms, -j, address
190 # ---------------------------------------------------------------------------
191
192
193 class TestJsonEnvelope:
194 def test_exit_code_zero_in_json(self, code_repo: pathlib.Path) -> None:
195 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
196 assert result.exit_code == 0, result.output
197 data = json.loads(result.output)
198 assert "exit_code" in data, "JSON must include exit_code"
199 assert data["exit_code"] == 0
200
201 def test_duration_ms_non_negative_float(self, code_repo: pathlib.Path) -> None:
202 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
203 assert result.exit_code == 0, result.output
204 data = json.loads(result.output)
205 assert "duration_ms" in data, "JSON must include duration_ms"
206 assert isinstance(data["duration_ms"], float | int)
207 assert data["duration_ms"] >= 0
208
209 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
210 result = runner.invoke(cli, ["code", "lineage", "-j", "billing.py::process_order"])
211 assert result.exit_code == 0, result.output
212 data = json.loads(result.output)
213 assert "events" in data, "-j must produce same JSON as --json"
214 assert "exit_code" in data
215
216 def test_j_alias_output_matches_json_flag(self, code_repo: pathlib.Path) -> None:
217 r1 = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
218 r2 = runner.invoke(cli, ["code", "lineage", "-j", "billing.py::process_order"])
219 assert r1.exit_code == 0
220 assert r2.exit_code == 0
221 d1 = json.loads(r1.output)
222 d2 = json.loads(r2.output)
223 # All structural keys must match (duration_ms may differ slightly)
224 for key in ("address", "total", "exit_code", "events", "stability_pct"):
225 assert d1[key] == d2[key], f"key {key!r} differs between -j and --json"
226
227 def test_address_echoed_in_json(self, code_repo: pathlib.Path) -> None:
228 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
229 assert result.exit_code == 0
230 data = json.loads(result.output)
231 assert data["address"] == "billing.py::process_order"
232
233 def test_json_schema_all_required_keys(self, code_repo: pathlib.Path) -> None:
234 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
235 assert result.exit_code == 0
236 data = json.loads(result.output)
237 required = {"address", "total", "events", "stability_pct", "modified_count",
238 "exit_code", "duration_ms"}
239 missing = required - set(data.keys())
240 assert not missing, f"JSON missing keys: {missing}"
241
242
243 # ---------------------------------------------------------------------------
244 # Context Fields — applied constraints echoed for agent verification
245 # ---------------------------------------------------------------------------
246
247
248 class TestJsonContextFields:
249 def test_filter_field_present_when_applied(self, code_repo: pathlib.Path) -> None:
250 result = runner.invoke(cli, [
251 "code", "lineage", "--json", "--filter", "created",
252 "billing.py::process_order",
253 ])
254 assert result.exit_code == 0
255 data = json.loads(result.output)
256 assert "filter" in data, "JSON must echo the applied filter"
257 assert data["filter"] == "created"
258
259 def test_filter_field_none_when_not_applied(self, code_repo: pathlib.Path) -> None:
260 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
261 assert result.exit_code == 0
262 data = json.loads(result.output)
263 assert "filter" in data
264 assert data["filter"] is None
265
266 def test_since_field_in_json_when_applied(self, code_repo: pathlib.Path) -> None:
267 result = runner.invoke(cli, [
268 "code", "lineage", "--json", "--since", "2020-01-01",
269 "billing.py::process_order",
270 ])
271 assert result.exit_code == 0
272 data = json.loads(result.output)
273 assert "since" in data, "JSON must echo the since date"
274 assert data["since"] == "2020-01-01"
275
276 def test_until_field_in_json_when_applied(self, code_repo: pathlib.Path) -> None:
277 result = runner.invoke(cli, [
278 "code", "lineage", "--json", "--until", "2099-01-01",
279 "billing.py::process_order",
280 ])
281 assert result.exit_code == 0
282 data = json.loads(result.output)
283 assert "until" in data, "JSON must echo the until date"
284 assert data["until"] == "2099-01-01"
285
286 def test_since_field_none_when_not_applied(self, code_repo: pathlib.Path) -> None:
287 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
288 assert result.exit_code == 0
289 data = json.loads(result.output)
290 assert "since" in data
291 assert data["since"] is None
292
293 def test_until_field_none_when_not_applied(self, code_repo: pathlib.Path) -> None:
294 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
295 assert result.exit_code == 0
296 data = json.loads(result.output)
297 assert "until" in data
298 assert data["until"] is None
299
300
301 # ---------------------------------------------------------------------------
302 # commit_id Format — sha256: prefix, not bare hex
303 # ---------------------------------------------------------------------------
304
305
306 class TestCommitIdFormat:
307 def test_commit_id_uses_sha256_prefix(self, code_repo: pathlib.Path) -> None:
308 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
309 assert result.exit_code == 0
310 data = json.loads(result.output)
311 for ev in data["events"]:
312 assert ev["commit_id"].startswith("sha256:"), (
313 f"commit_id must start with 'sha256:', got: {ev['commit_id']!r}"
314 )
315
316 def test_commit_id_full_length(self, code_repo: pathlib.Path) -> None:
317 """sha256:<64-hex> = 71 chars total — not truncated."""
318 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
319 assert result.exit_code == 0
320 data = json.loads(result.output)
321 for ev in data["events"]:
322 assert len(ev["commit_id"]) == 71, (
323 f"Expected sha256:<64-hex> (71 chars), got {len(ev['commit_id'])}: {ev['commit_id']!r}"
324 )
325
326
327 # ---------------------------------------------------------------------------
328 # Event Detail Fields — content_id and detail preservation
329 # ---------------------------------------------------------------------------
330
331
332 class TestEventDetailFields:
333 def test_modified_event_has_old_and_new_content_id(
334 self, modified_repo: pathlib.Path
335 ) -> None:
336 result = runner.invoke(cli, [
337 "code", "lineage", "--json", "--filter", "modified",
338 "billing.py::compute_total",
339 ])
340 assert result.exit_code == 0
341 data = json.loads(result.output)
342 for ev in data["events"]:
343 assert "old_content_id" in ev, "modified events must have old_content_id"
344 assert "new_content_id" in ev, "modified events must have new_content_id"
345
346 def test_created_event_has_new_content_id(self, code_repo: pathlib.Path) -> None:
347 result = runner.invoke(cli, [
348 "code", "lineage", "--json", "--filter", "created",
349 "billing.py::process_order",
350 ])
351 assert result.exit_code == 0
352 data = json.loads(result.output)
353 for ev in data["events"]:
354 assert "new_content_id" in ev, "created events must have new_content_id"
355
356 def test_renamed_event_has_detail_in_json(self, code_repo: pathlib.Path) -> None:
357 """renamed_from events must carry the source address in detail."""
358 result = runner.invoke(cli, [
359 "code", "lineage", "--json", "--filter", "renamed_from",
360 "billing.py::compute_invoice_total",
361 ])
362 assert result.exit_code == 0
363 data = json.loads(result.output)
364 for ev in data["events"]:
365 assert ev["event"] == "renamed_from"
366 assert "detail" in ev, "renamed_from events must include detail (source address)"
367 assert "::" in ev["detail"], f"detail should be a symbol address, got: {ev['detail']!r}"
368
369
370 # ---------------------------------------------------------------------------
371 # Kind Filters — all six values
372 # ---------------------------------------------------------------------------
373
374
375 class TestKindFilters:
376 def test_filter_created_only(self, code_repo: pathlib.Path) -> None:
377 result = runner.invoke(cli, [
378 "code", "lineage", "--json", "--filter", "created",
379 "billing.py::process_order",
380 ])
381 assert result.exit_code == 0
382 data = json.loads(result.output)
383 for ev in data["events"]:
384 assert ev["event"] == "created"
385
386 def test_filter_modified_only(self, modified_repo: pathlib.Path) -> None:
387 result = runner.invoke(cli, [
388 "code", "lineage", "--json", "--filter", "modified",
389 "billing.py::compute_total",
390 ])
391 assert result.exit_code == 0
392 data = json.loads(result.output)
393 for ev in data["events"]:
394 assert ev["event"] == "modified"
395
396 def test_filter_deleted_only(self, repo: pathlib.Path) -> None:
397 """Create then delete a symbol — filter should return only the delete event."""
398 (repo / "billing.py").write_text("def helper(): return 1\n")
399 runner.invoke(cli, ["code", "add", "billing.py"])
400 r = runner.invoke(cli, ["commit", "-m", "add helper"])
401 assert r.exit_code == 0, r.output
402
403 (repo / "billing.py").write_text("# helper removed\n")
404 runner.invoke(cli, ["code", "add", "billing.py"])
405 r = runner.invoke(cli, ["commit", "-m", "remove helper"])
406 assert r.exit_code == 0, r.output
407
408 result = runner.invoke(cli, [
409 "code", "lineage", "--json", "--filter", "deleted",
410 "billing.py::helper",
411 ])
412 assert result.exit_code == 0
413 data = json.loads(result.output)
414 for ev in data["events"]:
415 assert ev["event"] == "deleted"
416
417 def test_filter_renamed_from_only(self, code_repo: pathlib.Path) -> None:
418 result = runner.invoke(cli, [
419 "code", "lineage", "--json", "--filter", "renamed_from",
420 "billing.py::compute_invoice_total",
421 ])
422 assert result.exit_code == 0
423 data = json.loads(result.output)
424 for ev in data["events"]:
425 assert ev["event"] == "renamed_from"
426
427 def test_filter_returns_empty_for_unmatched_kind(self, code_repo: pathlib.Path) -> None:
428 """Filtering for 'deleted' on a symbol that was never deleted → empty events list."""
429 result = runner.invoke(cli, [
430 "code", "lineage", "--json", "--filter", "deleted",
431 "billing.py::process_order",
432 ])
433 assert result.exit_code == 0
434 data = json.loads(result.output)
435 assert data["total"] == 0
436 assert data["events"] == []
437
438 def test_invalid_filter_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
439 result = runner.invoke(cli, [
440 "code", "lineage", "--filter", "bogus_kind",
441 "billing.py::process_order",
442 ])
443 assert result.exit_code != 0
444
445
446 # ---------------------------------------------------------------------------
447 # Stability in JSON
448 # ---------------------------------------------------------------------------
449
450
451 class TestStabilityInJson:
452 def test_stability_pct_always_in_json(self, code_repo: pathlib.Path) -> None:
453 """stability_pct is emitted without the --stability flag — agents always get it."""
454 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
455 assert result.exit_code == 0
456 data = json.loads(result.output)
457 assert "stability_pct" in data
458 assert isinstance(data["stability_pct"], int)
459
460 def test_stability_pct_100_when_no_modifications(self, code_repo: pathlib.Path) -> None:
461 result = runner.invoke(cli, [
462 "code", "lineage", "--json", "--filter", "created",
463 "billing.py::process_order",
464 ])
465 assert result.exit_code == 0
466 data = json.loads(result.output)
467 # Only created events → no modifications → stability 100%
468 assert data["stability_pct"] == 100
469
470 def test_stability_pct_zero_when_all_filtered_to_modified(
471 self, modified_repo: pathlib.Path
472 ) -> None:
473 result = runner.invoke(cli, [
474 "code", "lineage", "--json", "--filter", "modified",
475 "billing.py::compute_total",
476 ])
477 assert result.exit_code == 0
478 data = json.loads(result.output)
479 if data["total"] > 0:
480 assert data["stability_pct"] == 0
481
482 def test_modified_count_matches_filtered_events(self, modified_repo: pathlib.Path) -> None:
483 result = runner.invoke(cli, [
484 "code", "lineage", "--json", "billing.py::compute_total",
485 ])
486 assert result.exit_code == 0
487 data = json.loads(result.output)
488 actual_modified = sum(1 for ev in data["events"] if ev["event"] == "modified")
489 assert data["modified_count"] == actual_modified
490
491
492 # ---------------------------------------------------------------------------
493 # --count flag
494 # ---------------------------------------------------------------------------
495
496
497 class TestCountFlag:
498 def test_count_only_outputs_integer(self, code_repo: pathlib.Path) -> None:
499 result = runner.invoke(cli, [
500 "code", "lineage", "--count", "billing.py::process_order",
501 ])
502 assert result.exit_code == 0
503 assert result.output.strip().isdigit()
504
505 def test_count_with_json_emits_structured_total(self, code_repo: pathlib.Path) -> None:
506 """--count --json emits full JSON (with 'total' field) not bare integer."""
507 result = runner.invoke(cli, [
508 "code", "lineage", "--count", "--json", "billing.py::process_order",
509 ])
510 assert result.exit_code == 0
511 data = json.loads(result.output)
512 assert "total" in data
513 assert isinstance(data["total"], int)
514 assert "exit_code" in data
515
516 def test_count_filter_combination(self, modified_repo: pathlib.Path) -> None:
517 """--count --filter modified returns count of only modified events."""
518 result = runner.invoke(cli, [
519 "code", "lineage", "--count", "--filter", "modified",
520 "billing.py::compute_total",
521 ])
522 assert result.exit_code == 0
523 count = int(result.output.strip())
524 assert count >= 2 # two modifications in modified_repo fixture
525
526
527 # ---------------------------------------------------------------------------
528 # Docstring / TypedDict export
529 # ---------------------------------------------------------------------------
530
531
532 class TestTypedDictExport:
533 def test_lineage_json_typeddict_importable(self) -> None:
534 """_LineageJson TypedDict must be importable from lineage module."""
535 from muse.cli.commands.lineage import _LineageJson # type: ignore[attr-defined]
536 assert _LineageJson is not None
537
538 def test_typeddict_has_required_fields(self) -> None:
539 from muse.cli.commands.lineage import _LineageJson # type: ignore[attr-defined]
540 annotations = _LineageJson.__annotations__
541 required = {"address", "total", "events", "stability_pct", "modified_count",
542 "exit_code", "duration_ms", "filter", "since", "until"}
543 missing = required - set(annotations)
544 assert not missing, f"_LineageJson missing annotations: {missing}"
545
546
547 # ---------------------------------------------------------------------------
548 # Classify replace — docstring gap: "impl_only" documented but never returned
549 # ---------------------------------------------------------------------------
550
551
552 class TestClassifyReplaceDocstringAccuracy:
553 """Verify _classify_replace only returns documented values."""
554
555 def test_signature_change_on_signature_keyword(self) -> None:
556 assert _classify_replace("signature changed", "") == "signature_change"
557
558 def test_full_rewrite_is_default(self) -> None:
559 result = _classify_replace("body rewritten entirely", "")
560 assert result in ("full_rewrite", "impl_only"), (
561 f"_classify_replace returned unexpected value: {result!r}"
562 )
563
564 def test_return_value_is_a_known_kind(self) -> None:
565 known = {"signature_change", "full_rewrite", "impl_only"}
566 for old_s, new_s in [
567 ("", ""),
568 ("signature changed", ""),
569 ("", "new signature here"),
570 ("impl updated", "impl updated v2"),
571 ("complete rewrite", "new logic"),
572 ]:
573 result = _classify_replace(old_s, new_s)
574 assert result in known, (
575 f"_classify_replace({old_s!r}, {new_s!r}) → {result!r} not in {known}"
576 )
577
578
579 # ---------------------------------------------------------------------------
580 # TestRegisterFlags — argparse-level verification
581 # ---------------------------------------------------------------------------
582
583
584 class TestRegisterFlags:
585 """Verify that register() wires --json / -j correctly."""
586
587 def _make_parser(self) -> "argparse.ArgumentParser":
588 import argparse
589 from muse.cli.commands.lineage import register
590 ap = argparse.ArgumentParser()
591 subs = ap.add_subparsers()
592 register(subs)
593 return ap
594
595 def test_json_flag_long(self) -> None:
596 ns = self._make_parser().parse_args(["lineage", "file.py::Fn", "--json"])
597 assert ns.json_out is True
598
599 def test_j_alias(self) -> None:
600 ns = self._make_parser().parse_args(["lineage", "file.py::Fn", "-j"])
601 assert ns.json_out is True
602
603 def test_default_is_text(self) -> None:
604 ns = self._make_parser().parse_args(["lineage", "file.py::Fn"])
605 assert ns.json_out is False
606
607 def test_dest_is_json_out(self) -> None:
608 ns = self._make_parser().parse_args(["lineage", "file.py::Fn", "-j"])
609 assert hasattr(ns, "json_out")
610 assert not hasattr(ns, "fmt")
File History 1 commit
sha256:74b5023693ac2ab80e3b89fddc66e0d60d7d931a1266d3f9294f645c3102fe76 tests/test_lineage_supercharge.py, tests/test_narrative_sup… Human 3 days ago