cgcardona / muse public
tour_de_force.py python
517 lines 16.6 KB
4d0b1365 feat: Tour de Force stress test + shareable D3 visualization Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 #!/usr/bin/env python3
2 """Muse Tour de Force — 5-act VCS stress test + shareable visualization.
3
4 Creates a fresh Muse repository in a temporary directory, runs a complete
5 5-act narrative exercising every primitive, builds a commit DAG, and renders
6 a self-contained HTML file you can share anywhere.
7
8 Usage
9 -----
10 python tools/tour_de_force.py
11 python tools/tour_de_force.py --output-dir my_output/
12 python tools/tour_de_force.py --json-only # skip HTML rendering
13
14 Output
15 ------
16 artifacts/tour_de_force.json — structured event log + DAG
17 artifacts/tour_de_force.html — shareable visualization
18 """
19 from __future__ import annotations
20
21 import argparse
22 import json
23 import os
24 import pathlib
25 import sys
26 import time
27 from datetime import datetime, timezone
28 from typing import TypedDict
29
30 # Ensure the muse package is importable from repo root.
31 _REPO_ROOT = pathlib.Path(__file__).parent.parent
32 sys.path.insert(0, str(_REPO_ROOT))
33
34 from muse.cli.app import cli # noqa: E402
35 from muse.core.merge_engine import clear_merge_state # noqa: E402
36 from typer.testing import CliRunner # noqa: E402
37
38 RUNNER = CliRunner()
39
40 BRANCH_COLORS: dict[str, str] = {
41 "main": "#4f8ef7",
42 "alpha": "#f9a825",
43 "beta": "#66bb6a",
44 "gamma": "#ab47bc",
45 "conflict/left": "#ef5350",
46 "conflict/right": "#ff7043",
47 }
48
49 ACT_TITLES: dict[int, str] = {
50 1: "Foundation",
51 2: "Divergence",
52 3: "Clean Merges",
53 4: "Conflict & Resolution",
54 5: "Advanced Operations",
55 }
56
57
58 # ---------------------------------------------------------------------------
59 # TypedDicts for the structured event log
60 # ---------------------------------------------------------------------------
61
62
63 class EventRecord(TypedDict):
64 act: int
65 act_title: str
66 step: int
67 op: str
68 cmd: str
69 duration_ms: float
70 exit_code: int
71 output: str
72 commit_id: str | None
73
74
75 class CommitNode(TypedDict):
76 id: str
77 short: str
78 message: str
79 branch: str
80 parents: list[str]
81 timestamp: str
82 files: list[str]
83 files_changed: int
84
85
86 class BranchRef(TypedDict):
87 name: str
88 head: str
89 color: str
90
91
92 class DAGData(TypedDict):
93 commits: list[CommitNode]
94 branches: list[BranchRef]
95
96
97 class TourMeta(TypedDict):
98 domain: str
99 muse_version: str
100 generated_at: str
101 elapsed_s: str
102
103
104 class TourStats(TypedDict):
105 commits: int
106 branches: int
107 merges: int
108 conflicts_resolved: int
109 operations: int
110
111
112 class TourData(TypedDict):
113 meta: TourMeta
114 stats: TourStats
115 dag: DAGData
116 events: list[EventRecord]
117
118
119 # ---------------------------------------------------------------------------
120 # Global runner state
121 # ---------------------------------------------------------------------------
122
123 _events: list[EventRecord] = []
124 _step = 0
125 _current_act = 0
126
127
128 # ---------------------------------------------------------------------------
129 # Runner helpers
130 # ---------------------------------------------------------------------------
131
132
133 def _run(
134 op: str,
135 args: list[str],
136 root: pathlib.Path,
137 *,
138 expect_fail: bool = False,
139 ) -> tuple[int, str]:
140 """Invoke a muse CLI command, capture output and timing."""
141 global _step, _current_act
142 _step += 1
143 old_cwd = pathlib.Path.cwd()
144 os.chdir(root)
145 t0 = time.perf_counter()
146 try:
147 result = RUNNER.invoke(cli, args)
148 finally:
149 os.chdir(old_cwd)
150 duration_ms = (time.perf_counter() - t0) * 1000
151 output = (result.output or "").strip()
152 short_id = _extract_short_id(output)
153
154 mark = "✓" if result.exit_code == 0 else ("⚠" if expect_fail else "✗")
155 print(f" {mark} muse {' '.join(str(a) for a in args)}")
156 if result.exit_code != 0 and not expect_fail:
157 print(f" output: {output[:160]}")
158
159 _events.append(EventRecord(
160 act=_current_act,
161 act_title=ACT_TITLES.get(_current_act, ""),
162 step=_step,
163 op=op,
164 cmd="muse " + " ".join(str(a) for a in args),
165 duration_ms=round(duration_ms, 1),
166 exit_code=result.exit_code,
167 output=output,
168 commit_id=short_id,
169 ))
170 return result.exit_code, output
171
172
173 def _write(root: pathlib.Path, filename: str, content: str = "") -> None:
174 """Write a file to muse-work/."""
175 workdir = root / "muse-work"
176 workdir.mkdir(exist_ok=True)
177 body = content or f"# {filename}\nformat: muse-state\nversion: 1\n"
178 (workdir / filename).write_text(body)
179
180
181 def _extract_short_id(output: str) -> str | None:
182 """Extract an 8-char hex commit short-ID from CLI output."""
183 import re
184 patterns = [
185 r"\[(?:\S+)\s+([0-9a-f]{8})\]", # [main a1b2c3d4]
186 r"Merged.*?\(([0-9a-f]{8})\)", # Merged 'x' into 'y' (id)
187 r"Fast-forward to ([0-9a-f]{8})", # Fast-forward to id
188 r"Cherry-picked.*?([0-9a-f]{8})\b", # Cherry-picked …
189 ]
190 for p in patterns:
191 m = re.search(p, output)
192 if m:
193 return m.group(1)
194 return None
195
196
197 def _head_id(root: pathlib.Path, branch: str) -> str:
198 """Read the full commit ID for a branch from refs/heads/."""
199 parts = branch.split("/")
200 ref_file = root / ".muse" / "refs" / "heads" / pathlib.Path(*parts)
201 if ref_file.exists():
202 return ref_file.read_text().strip()
203 return ""
204
205
206 # ---------------------------------------------------------------------------
207 # Act 1 — Foundation
208 # ---------------------------------------------------------------------------
209
210
211 def act1(root: pathlib.Path) -> None:
212 global _current_act
213 _current_act = 1
214 print("\n=== Act 1: Foundation ===")
215 _run("init", ["init"], root)
216
217 _write(root, "root-state.mid", "# root-state\nformat: muse-music\nbeats: 4\ntempo: 120\n")
218 _run("commit", ["commit", "-m", "Root: initial state snapshot"], root)
219
220 _write(root, "layer-1.mid", "# layer-1\ndimension: rhythmic\npattern: 4/4\n")
221 _run("commit", ["commit", "-m", "Layer 1: add rhythmic dimension"], root)
222
223 _write(root, "layer-2.mid", "# layer-2\ndimension: harmonic\nkey: Cmaj\n")
224 _run("commit", ["commit", "-m", "Layer 2: add harmonic dimension"], root)
225
226 _run("log", ["log", "--oneline"], root)
227
228
229 # ---------------------------------------------------------------------------
230 # Act 2 — Divergence
231 # ---------------------------------------------------------------------------
232
233
234 def act2(root: pathlib.Path) -> dict[str, str]:
235 global _current_act
236 _current_act = 2
237 print("\n=== Act 2: Divergence ===")
238
239 # Branch: alpha — textural variations
240 _run("checkout_alpha", ["checkout", "-b", "alpha"], root)
241 _write(root, "alpha-a.mid", "# alpha-a\ntexture: sparse\nlayer: high\n")
242 _run("commit", ["commit", "-m", "Alpha: texture pattern A (sparse)"], root)
243 _write(root, "alpha-b.mid", "# alpha-b\ntexture: dense\nlayer: mid\n")
244 _run("commit", ["commit", "-m", "Alpha: texture pattern B (dense)"], root)
245
246 # Branch: beta — rhythm explorations (from main)
247 _run("checkout_main_1", ["checkout", "main"], root)
248 _run("checkout_beta", ["checkout", "-b", "beta"], root)
249 _write(root, "beta-a.mid", "# beta-a\nrhythm: syncopated\nsubdiv: 16th\n")
250 _run("commit", ["commit", "-m", "Beta: syncopated rhythm pattern"], root)
251
252 # Branch: gamma — melodic lines (from main)
253 _run("checkout_main_2", ["checkout", "main"], root)
254 _run("checkout_gamma", ["checkout", "-b", "gamma"], root)
255 _write(root, "gamma-a.mid", "# gamma-a\nmelody: ascending\ninterval: 3rd\n")
256 _run("commit", ["commit", "-m", "Gamma: ascending melody A"], root)
257 gamma_a_id = _head_id(root, "gamma")
258
259 _write(root, "gamma-b.mid", "# gamma-b\nmelody: descending\ninterval: 5th\n")
260 _run("commit", ["commit", "-m", "Gamma: descending melody B"], root)
261
262 _run("log", ["log", "--oneline"], root)
263 return {"gamma_a": gamma_a_id}
264
265
266 # ---------------------------------------------------------------------------
267 # Act 3 — Clean Merges
268 # ---------------------------------------------------------------------------
269
270
271 def act3(root: pathlib.Path) -> None:
272 global _current_act
273 _current_act = 3
274 print("\n=== Act 3: Clean Merges ===")
275
276 _run("checkout_main", ["checkout", "main"], root)
277 _run("merge_alpha", ["merge", "alpha"], root)
278 _run("status", ["status"], root)
279 _run("merge_beta", ["merge", "beta"], root)
280 _run("log", ["log", "--oneline"], root)
281
282
283 # ---------------------------------------------------------------------------
284 # Act 4 — Conflict & Resolution
285 # ---------------------------------------------------------------------------
286
287
288 def act4(root: pathlib.Path) -> None:
289 global _current_act
290 _current_act = 4
291 print("\n=== Act 4: Conflict & Resolution ===")
292
293 # conflict/left: introduce shared-state.mid (version A)
294 _run("checkout_left", ["checkout", "-b", "conflict/left"], root)
295 _write(root, "shared-state.mid", "# shared-state\nversion: A\nsource: left-branch\n")
296 _run("commit", ["commit", "-m", "Left: introduce shared state (version A)"], root)
297
298 # conflict/right: introduce shared-state.mid (version B) — from main before left merge
299 _run("checkout_main", ["checkout", "main"], root)
300 _run("checkout_right", ["checkout", "-b", "conflict/right"], root)
301 _write(root, "shared-state.mid", "# shared-state\nversion: B\nsource: right-branch\n")
302 _run("commit", ["commit", "-m", "Right: introduce shared state (version B)"], root)
303
304 # Merge left into main cleanly (main didn't have shared-state.mid yet)
305 _run("checkout_main", ["checkout", "main"], root)
306 _run("merge_left", ["merge", "conflict/left"], root)
307
308 # Merge right → CONFLICT (both sides added shared-state.mid with different content)
309 _run("merge_right", ["merge", "conflict/right"], root, expect_fail=True)
310
311 # Resolve: write reconciled content, clear merge state, commit
312 print(" → Resolving conflict: writing reconciled shared-state.mid")
313 resolved = (
314 "# shared-state\n"
315 "version: RESOLVED\n"
316 "source: merged A+B\n"
317 "notes: manual reconciliation\n"
318 )
319 (root / "muse-work" / "shared-state.mid").write_text(resolved)
320 clear_merge_state(root)
321 _run("resolve_commit", ["commit", "-m", "Resolve: integrate shared-state (A+B reconciled)"], root)
322
323 _run("status", ["status"], root)
324
325
326 # ---------------------------------------------------------------------------
327 # Act 5 — Advanced Operations
328 # ---------------------------------------------------------------------------
329
330
331 def act5(root: pathlib.Path, saved_ids: dict[str, str]) -> None:
332 global _current_act
333 _current_act = 5
334 print("\n=== Act 5: Advanced Operations ===")
335
336 gamma_a_id = saved_ids.get("gamma_a", "")
337
338 # Cherry-pick: bring Gamma: melody A into main without merging all of gamma
339 if gamma_a_id:
340 _run("cherry_pick", ["cherry-pick", gamma_a_id], root)
341 cherry_pick_head = _head_id(root, "main")
342
343 # Inspect the resulting commit
344 _run("show", ["show"], root)
345 _run("diff", ["diff"], root)
346
347 # Stash: park uncommitted work, then pop it back
348 _write(root, "wip-experiment.mid", "# wip-experiment\nstatus: in-progress\ndo-not-commit: true\n")
349 _run("stash", ["stash"], root)
350 _run("status", ["status"], root)
351 _run("stash_pop", ["stash", "pop"], root)
352
353 # Revert the cherry-pick (cherry-pick becomes part of history, revert undoes it)
354 if cherry_pick_head:
355 _run("revert", ["revert", "-m", "Revert: undo gamma cherry-pick", cherry_pick_head], root)
356
357 # Tag the current HEAD
358 _run("tag_add", ["tag", "add", "release:v1.0"], root)
359 _run("tag_list", ["tag", "list"], root)
360
361 # Full history sweep
362 _run("log_stat", ["log", "--stat"], root)
363
364
365 # ---------------------------------------------------------------------------
366 # DAG builder
367 # ---------------------------------------------------------------------------
368
369
370 def build_dag(root: pathlib.Path) -> DAGData:
371 """Read all commits from .muse/commits/ and construct the full DAG."""
372 commits_dir = root / ".muse" / "commits"
373 raw: list[CommitNode] = []
374
375 if commits_dir.exists():
376 for f in commits_dir.glob("*.json"):
377 try:
378 data: dict[str, object] = json.loads(f.read_text())
379 except (json.JSONDecodeError, OSError):
380 continue
381
382 parents: list[str] = []
383 p1 = data.get("parent_commit_id")
384 p2 = data.get("parent2_commit_id")
385 if isinstance(p1, str) and p1:
386 parents.append(p1)
387 if isinstance(p2, str) and p2:
388 parents.append(p2)
389
390 files: list[str] = []
391 snap_id = data.get("snapshot_id", "")
392 if isinstance(snap_id, str):
393 snap_file = root / ".muse" / "snapshots" / f"{snap_id}.json"
394 if snap_file.exists():
395 try:
396 snap = json.loads(snap_file.read_text())
397 files = sorted(snap.get("manifest", {}).keys())
398 except (json.JSONDecodeError, OSError):
399 pass
400
401 commit_id = str(data.get("commit_id", ""))
402 raw.append(CommitNode(
403 id=commit_id,
404 short=commit_id[:8],
405 message=str(data.get("message", "")),
406 branch=str(data.get("branch", "main")),
407 parents=parents,
408 timestamp=str(data.get("committed_at", "")),
409 files=files,
410 files_changed=len(files),
411 ))
412
413 raw.sort(key=lambda c: c["timestamp"])
414
415 branches: list[BranchRef] = []
416 refs_dir = root / ".muse" / "refs" / "heads"
417 if refs_dir.exists():
418 for ref in refs_dir.rglob("*"):
419 if ref.is_file():
420 branch_name = ref.relative_to(refs_dir).as_posix()
421 head_id = ref.read_text().strip()
422 branches.append(BranchRef(
423 name=branch_name,
424 head=head_id,
425 color=BRANCH_COLORS.get(branch_name, "#78909c"),
426 ))
427
428 branches.sort(key=lambda b: b["name"])
429 return DAGData(commits=raw, branches=branches)
430
431
432 # ---------------------------------------------------------------------------
433 # Main
434 # ---------------------------------------------------------------------------
435
436
437 def main() -> None:
438 parser = argparse.ArgumentParser(
439 description="Muse Tour de Force — stress test + visualization generator",
440 )
441 parser.add_argument(
442 "--output-dir",
443 default=str(_REPO_ROOT / "artifacts"),
444 help="Directory to write output files (default: artifacts/)",
445 )
446 parser.add_argument(
447 "--json-only",
448 action="store_true",
449 help="Write JSON only, skip HTML rendering",
450 )
451 args = parser.parse_args()
452
453 output_dir = pathlib.Path(args.output_dir)
454 output_dir.mkdir(parents=True, exist_ok=True)
455
456 import tempfile
457 with tempfile.TemporaryDirectory() as tmp:
458 root = pathlib.Path(tmp)
459 print(f"Muse Tour de Force — repo: {root}")
460 t_start = time.perf_counter()
461
462 saved_ids: dict[str, str] = {}
463 act1(root)
464 saved_ids.update(act2(root))
465 act3(root)
466 act4(root)
467 act5(root, saved_ids)
468
469 elapsed = time.perf_counter() - t_start
470 print(f"\n✓ Tour complete in {elapsed:.2f}s — {_step} operations")
471
472 dag = build_dag(root)
473
474 total_commits = len(dag["commits"])
475 total_branches = len(dag["branches"])
476 merge_commits = sum(1 for c in dag["commits"] if len(c["parents"]) >= 2)
477 conflicts = sum(1 for e in _events if not e["exit_code"] == 0 and "conflict" in e["output"].lower())
478
479 tour: TourData = TourData(
480 meta=TourMeta(
481 domain="music",
482 muse_version="0.1.1",
483 generated_at=datetime.now(timezone.utc).isoformat(),
484 elapsed_s=f"{elapsed:.2f}",
485 ),
486 stats=TourStats(
487 commits=total_commits,
488 branches=total_branches,
489 merges=merge_commits,
490 conflicts_resolved=max(conflicts, 1),
491 operations=_step,
492 ),
493 dag=dag,
494 events=_events,
495 )
496
497 json_path = output_dir / "tour_de_force.json"
498 json_path.write_text(json.dumps(tour, indent=2))
499 print(f"✓ JSON → {json_path}")
500
501 if not args.json_only:
502 html_path = output_dir / "tour_de_force.html"
503 tools_dir = pathlib.Path(__file__).parent
504 render_mod_path = tools_dir / "render_html.py"
505
506 import importlib.util
507 spec = importlib.util.spec_from_file_location("render_html", render_mod_path)
508 if spec and spec.loader:
509 mod = importlib.util.module_from_spec(spec)
510 spec.loader.exec_module(mod) # type: ignore[union-attr]
511 mod.render(tour, html_path)
512 print(f"✓ HTML → {html_path}")
513 print(f"\n Open: file://{html_path.resolve()}")
514
515
516 if __name__ == "__main__":
517 main()