gabriel / muse public
test_show_json_schema.py python
465 lines 18.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Tests for the canonical ``muse read --json`` schema.
2
3 ``muse read`` is how agents inspect individual commits — metadata, delta,
4 and provenance in one shot. The JSON schema must be complete and stable.
5
6 Schema (with --stat, default)
7 ------------------------------
8 ::
9
10 {
11 "commit_id": "sha256:<64-hex>",
12 "repo_id": str,
13 "branch": str,
14 "snapshot_id": str,
15 "message": str,
16 "committed_at": str, // ISO 8601 with timezone
17 "parent_commit_id": str | null,
18 "parent2_commit_id": str | null,
19 "author": str,
20 "metadata": dict,
21 "structured_delta": dict | null, // absent with --no-delta
22 "sem_ver_bump": str, // "none" | "patch" | "minor" | "major"
23 "breaking_changes": [str, ...],
24 "agent_id": str, // "" for human commits
25 "model_id": str, // "" for human commits
26 "toolchain_id": str,
27 "prompt_hash": str,
28 "signature": str,
29 "signer_public_key": str,
30 "signer_key_id": str,
31 "format_version": int,
32 "reviewed_by": [str, ...],
33 "test_runs": int,
34 "files_added": [str, ...], // absent with --no-stat
35 "files_removed": [str, ...], // absent with --no-stat
36 "files_modified": [str, ...], // absent with --no-stat
37 "total_changes": int // absent with --no-stat
38 }
39
40 Coverage
41 --------
42 I Schema invariants
43 I1 All required keys present (full provenance set)
44 I2 commit_id is sha256:-prefixed
45 I3 committed_at is ISO 8601 with timezone
46 I4 sem_ver_bump is a valid enum value
47 I5 breaking_changes is always a list
48 I6 reviewed_by is always a list
49 I7 test_runs is always an int
50
51 II Agent provenance
52 II1 agent_id populated from --agent-id flag
53 II2 model_id populated from --model-id flag
54 II3 agent_id is empty string (not null) for human commits
55 II4 model_id is empty string (not null) for human commits
56 II5 toolchain_id is a string (never null)
57
58 III File stats
59 III1 total_changes present with --stat (default)
60 III2 total_changes = len(files_added)+len(files_modified)+len(files_removed)
61 III3 total_changes absent with --no-stat
62 III4 files_added/removed/modified absent with --no-stat
63
64 IV Error handling (agent-friendly)
65 IV1 Non-existent commit exits 1 cleanly (no traceback)
66 IV2 --json + non-existent ref → stdout has JSON {"error": ...}
67 IV3 JSON error has "error", "ref", "message" keys
68 IV4 Invalid sha256: hex digits → same clean JSON error, exit 1
69 IV5 Ambiguous prefix → JSON error with "ambiguous_ref" error key
70
71 V Structured delta
72 V1 structured_delta present on non-initial commit
73 V2 structured_delta is null on initial commit (no parent to diff against)
74 V3 --no-delta omits structured_delta key entirely
75 """
76
77 from __future__ import annotations
78
79 import json
80 import pathlib
81
82 import pytest
83
84 from tests.cli_test_helper import CliRunner
85 from muse.core._types import long_id
86
87 cli = None
88 runner = CliRunner()
89
90 _REQUIRED_KEYS = {
91 # Identity
92 "commit_id", "repo_id", "branch", "snapshot_id",
93 # Content
94 "message", "committed_at", "parent_commit_id", "parent2_commit_id",
95 "author", "metadata", "structured_delta",
96 # Semantic versioning
97 "sem_ver_bump", "breaking_changes",
98 # Agent provenance (all must be present, empty string for humans)
99 "agent_id", "model_id", "toolchain_id",
100 "prompt_hash", "signature", "signer_public_key", "signer_key_id",
101 "format_version",
102 # CRDT annotation fields
103 "reviewed_by", "test_runs",
104 # File stat fields (present with default --stat)
105 "files_added", "files_removed", "files_modified", "total_changes",
106 }
107
108 _VALID_SEM_VER_BUMPS = {"none", "patch", "minor", "major"}
109
110
111 def _env(root: pathlib.Path) -> dict[str, str]:
112 return {"MUSE_REPO_ROOT": str(root)}
113
114
115 def _show(root: pathlib.Path, *flags: str) -> dict:
116 result = runner.invoke(cli, ["read", "--json"] + list(flags), env=_env(root))
117 assert result.exit_code == 0, f"show --json failed:\n{result.output}"
118 return json.loads(result.output.strip())
119
120
121 def _show_raw(root: pathlib.Path, *args: str):
122 """Return the raw InvokeResult (not parsed) for error-path tests."""
123 return runner.invoke(cli, ["read", "--json"] + list(args), env=_env(root))
124
125
126 @pytest.fixture()
127 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
128 """Code-domain repo with one committed file, no agent provenance."""
129 monkeypatch.chdir(tmp_path)
130 env = _env(tmp_path)
131 result = runner.invoke(cli, ["init", "--domain", "code"], env=env)
132 assert result.exit_code == 0, result.output
133 (tmp_path / "module.py").write_text("def greet():\n return 'hello'\n")
134 runner.invoke(cli, ["code", "add", "module.py"], env=env)
135 result = runner.invoke(cli, ["commit", "-m", "initial"], env=env)
136 assert result.exit_code == 0, result.output
137 return tmp_path
138
139
140 @pytest.fixture()
141 def repo_with_two_commits(
142 repo: pathlib.Path,
143 monkeypatch: pytest.MonkeyPatch,
144 ) -> pathlib.Path:
145 """Extends repo fixture with a second commit that modifies module.py."""
146 env = _env(repo)
147 (repo / "module.py").write_text(
148 "def greet():\n return 'hello'\n\ndef farewell():\n return 'bye'\n"
149 )
150 runner.invoke(cli, ["code", "add", "module.py"], env=env)
151 result = runner.invoke(cli, ["commit", "-m", "add farewell"], env=env)
152 assert result.exit_code == 0, result.output
153 return repo
154
155
156 # ---------------------------------------------------------------------------
157 # I Schema invariants
158 # ---------------------------------------------------------------------------
159
160
161 class TestSchemaInvariantsI:
162 def test_I1_all_required_keys_present(
163 self, repo_with_two_commits: pathlib.Path
164 ) -> None:
165 """I1: Every required key must be present in the default show --json output."""
166 data = _show(repo_with_two_commits)
167 missing = _REQUIRED_KEYS - data.keys()
168 assert not missing, f"Missing required keys in show --json: {missing}"
169
170 def test_I2_commit_id_sha256_prefixed(self, repo: pathlib.Path) -> None:
171 """I2: commit_id must start with 'sha256:'."""
172 data = _show(repo)
173 assert data["commit_id"].startswith("sha256:"), (
174 f"commit_id must be sha256:-prefixed, got {data['commit_id']!r}"
175 )
176
177 def test_I3_committed_at_is_iso8601_with_tz(self, repo: pathlib.Path) -> None:
178 """I3: committed_at must parse as ISO 8601 with timezone info."""
179 import datetime
180 data = _show(repo)
181 dt = datetime.datetime.fromisoformat(data["committed_at"])
182 assert dt.tzinfo is not None, (
183 f"committed_at lacks timezone: {data['committed_at']!r}"
184 )
185
186 def test_I4_sem_ver_bump_valid_enum(self, repo: pathlib.Path) -> None:
187 """I4: sem_ver_bump must be one of the four valid values."""
188 data = _show(repo)
189 assert data["sem_ver_bump"] in _VALID_SEM_VER_BUMPS, (
190 f"sem_ver_bump {data['sem_ver_bump']!r} not in {_VALID_SEM_VER_BUMPS}"
191 )
192
193 def test_I5_breaking_changes_always_list(self, repo: pathlib.Path) -> None:
194 """I5: breaking_changes is always a list (never null or absent)."""
195 data = _show(repo)
196 assert isinstance(data["breaking_changes"], list), (
197 f"breaking_changes must be list, got {type(data['breaking_changes'])}"
198 )
199
200 def test_I6_reviewed_by_always_list(self, repo: pathlib.Path) -> None:
201 """I6: reviewed_by is always a list (CRDT ORSet)."""
202 data = _show(repo)
203 assert isinstance(data["reviewed_by"], list), (
204 f"reviewed_by must be list, got {type(data['reviewed_by'])}"
205 )
206
207 def test_I7_test_runs_always_int(self, repo: pathlib.Path) -> None:
208 """I7: test_runs is always an int (CRDT GCounter)."""
209 data = _show(repo)
210 assert isinstance(data["test_runs"], int), (
211 f"test_runs must be int, got {type(data['test_runs'])}"
212 )
213
214
215 # ---------------------------------------------------------------------------
216 # II Agent provenance
217 # ---------------------------------------------------------------------------
218
219
220 class TestAgentProvenanceII:
221 def test_II1_agent_id_populated_from_flag(
222 self, repo: pathlib.Path
223 ) -> None:
224 """II1: --agent-id value appears in agent_id field."""
225 env = _env(repo)
226 (repo / "helper.py").write_text("x = 1\n")
227 runner.invoke(cli, ["code", "add", "helper.py"], env=env)
228 runner.invoke(
229 cli,
230 ["commit", "-m", "agent commit", "--agent-id", "test-bot"],
231 env=env,
232 )
233 data = _show(repo)
234 assert data["agent_id"] == "test-bot", (
235 f"Expected agent_id='test-bot', got {data['agent_id']!r}"
236 )
237
238 def test_II2_model_id_populated_from_flag(
239 self, repo: pathlib.Path
240 ) -> None:
241 """II2: --model-id value appears in model_id field."""
242 env = _env(repo)
243 (repo / "helper2.py").write_text("y = 2\n")
244 runner.invoke(cli, ["code", "add", "helper2.py"], env=env)
245 runner.invoke(
246 cli,
247 ["commit", "-m", "model commit", "--model-id", "claude-opus-4"],
248 env=env,
249 )
250 data = _show(repo)
251 assert data["model_id"] == "claude-opus-4", (
252 f"Expected model_id='claude-opus-4', got {data['model_id']!r}"
253 )
254
255 def test_II3_agent_id_empty_string_for_human_commit(
256 self, repo: pathlib.Path
257 ) -> None:
258 """II3: agent_id is empty string (not null) for human commits."""
259 data = _show(repo)
260 assert data["agent_id"] == "", (
261 f"agent_id must be '' for human commit, got {data['agent_id']!r}"
262 )
263
264 def test_II4_model_id_empty_string_for_human_commit(
265 self, repo: pathlib.Path
266 ) -> None:
267 """II4: model_id is empty string (not null) for human commits."""
268 data = _show(repo)
269 assert data["model_id"] == "", (
270 f"model_id must be '' for human commit, got {data['model_id']!r}"
271 )
272
273 def test_II5_toolchain_id_is_string_not_null(self, repo: pathlib.Path) -> None:
274 """II5: toolchain_id is always a string (empty for human commits)."""
275 data = _show(repo)
276 assert isinstance(data["toolchain_id"], str), (
277 f"toolchain_id must be str (never null), got {type(data['toolchain_id'])}"
278 )
279
280
281 # ---------------------------------------------------------------------------
282 # III File stats
283 # ---------------------------------------------------------------------------
284
285
286 class TestFileStatsIII:
287 def test_III1_total_changes_present_by_default(
288 self, repo_with_two_commits: pathlib.Path
289 ) -> None:
290 """III1: total_changes is present in default JSON output."""
291 data = _show(repo_with_two_commits)
292 assert "total_changes" in data, (
293 f"total_changes missing from show --json output"
294 )
295
296 def test_III2_total_changes_equals_sum_of_buckets(
297 self, repo_with_two_commits: pathlib.Path
298 ) -> None:
299 """III2: total_changes = len(files_added) + len(files_modified) + len(files_removed)."""
300 data = _show(repo_with_two_commits)
301 expected = (
302 len(data["files_added"])
303 + len(data["files_modified"])
304 + len(data["files_removed"])
305 )
306 assert data["total_changes"] == expected, (
307 f"total_changes {data['total_changes']} != "
308 f"len(added={data['files_added']}) + len(modified={data['files_modified']}) "
309 f"+ len(removed={data['files_removed']}) = {expected}"
310 )
311
312 def test_III3_total_changes_absent_with_no_stat(
313 self, repo: pathlib.Path
314 ) -> None:
315 """III3: total_changes is absent when --no-stat is used."""
316 result = runner.invoke(
317 cli, ["read", "--json", "--no-stat"], env=_env(repo)
318 )
319 assert result.exit_code == 0
320 data = json.loads(result.output.strip())
321 assert "total_changes" not in data, (
322 "total_changes must not appear with --no-stat"
323 )
324
325 def test_III4_file_buckets_absent_with_no_stat(self, repo: pathlib.Path) -> None:
326 """III4: files_added/removed/modified absent with --no-stat."""
327 result = runner.invoke(
328 cli, ["read", "--json", "--no-stat"], env=_env(repo)
329 )
330 assert result.exit_code == 0
331 data = json.loads(result.output.strip())
332 assert "files_added" not in data
333 assert "files_removed" not in data
334 assert "files_modified" not in data
335
336
337 # ---------------------------------------------------------------------------
338 # IV Error handling
339 # ---------------------------------------------------------------------------
340
341
342 class TestErrorHandlingIV:
343 def test_IV1_nonexistent_ref_exits_1(self, repo: pathlib.Path) -> None:
344 """IV1: Non-existent commit ref exits 1 without traceback."""
345 result = _show_raw(repo, long_id("a" * 64))
346 assert result.exit_code == 1, (
347 f"Expected exit code 1 for nonexistent ref, got {result.exit_code}"
348 )
349
350 def test_IV2_json_error_on_nonexistent_ref(self, repo: pathlib.Path) -> None:
351 """IV2: --json with nonexistent ref emits JSON on stdout (not a crash)."""
352 result = _show_raw(repo, long_id("a" * 64))
353 # Find the JSON line (stdout) — the ❌ text goes to stderr and may appear
354 # interleaved in the combined output captured by CliRunner.
355 json_line = next(
356 (l for l in result.output.strip().splitlines() if l.startswith("{")),
357 None,
358 )
359 assert json_line is not None, (
360 f"No JSON line found in output for nonexistent ref: {result.output!r}"
361 )
362 try:
363 data = json.loads(json_line)
364 except json.JSONDecodeError as exc:
365 pytest.fail(f"JSON line is not valid JSON: {json_line!r} — {exc}")
366 assert "error" in data
367
368 def test_IV3_json_error_has_required_keys(self, repo: pathlib.Path) -> None:
369 """IV3: JSON error payload has 'error', 'ref', 'message' keys."""
370 result = _show_raw(repo, long_id("b" * 64))
371 # Parse the last JSON-looking line
372 json_line = next(
373 (l for l in reversed(result.output.strip().splitlines())
374 if l.startswith("{")),
375 None,
376 )
377 assert json_line is not None, f"No JSON line in output: {result.output!r}"
378 data = json.loads(json_line)
379 assert "error" in data, f"'error' key missing from error JSON: {data}"
380 assert "ref" in data, f"'ref' key missing from error JSON: {data}"
381 assert "message" in data, f"'message' key missing from error JSON: {data}"
382
383 def test_IV4_invalid_sha256_hex_exits_1(self, repo: pathlib.Path) -> None:
384 """IV4: sha256: prefix with non-hex chars exits 1 cleanly."""
385 result = _show_raw(repo, "sha256:notvalidhex")
386 assert result.exit_code == 1
387 # Output must not contain a Python traceback
388 assert "Traceback" not in result.output
389 assert "Traceback" not in (result.stderr or "")
390
391 def test_IV5_ambiguous_prefix_returns_json_error(
392 self, repo: pathlib.Path
393 ) -> None:
394 """IV5: When multiple commits match a prefix, return ambiguous_ref error."""
395 env = _env(repo)
396 # Create enough commits that there's guaranteed to be a short common prefix
397 # We simulate this by checking the behavior — even a single commit should
398 # handle a 1-char prefix that might match multiple commits gracefully.
399 # The key invariant: ambiguous_ref must NOT return "commit_not_found".
400 result = runner.invoke(
401 cli,
402 ["log", "--json", "-n", "1"],
403 env=env,
404 )
405 assert result.exit_code == 0
406 log_data = json.loads(result.output.strip())
407 head_id = log_data["commits"][0]["commit_id"]
408 # Use a 1-char hex prefix with sha256: prefix retained
409 short_prefix = head_id[:len("sha256:") + 1]
410 result2 = _show_raw(repo, short_prefix)
411 # Either found (1 match) or ambiguous (>1 match) — must NOT crash
412 assert result2.exit_code in (0, 1), (
413 f"Unexpected exit code {result2.exit_code} for prefix {short_prefix!r}"
414 )
415 assert "Traceback" not in result2.output
416 if result2.exit_code == 1:
417 # Should produce JSON with either "commit_not_found" or "ambiguous_ref"
418 json_line = next(
419 (l for l in reversed(result2.output.strip().splitlines())
420 if l.startswith("{")),
421 None,
422 )
423 if json_line:
424 data = json.loads(json_line)
425 assert data["error"] in ("commit_not_found", "ambiguous_ref"), (
426 f"Expected error key to be 'commit_not_found' or 'ambiguous_ref', "
427 f"got {data['error']!r}"
428 )
429
430
431 # ---------------------------------------------------------------------------
432 # V Structured delta
433 # ---------------------------------------------------------------------------
434
435
436 class TestStructuredDeltaV:
437 def test_V1_structured_delta_present_on_second_commit(
438 self, repo_with_two_commits: pathlib.Path
439 ) -> None:
440 """V1: structured_delta is non-null on a commit with a parent."""
441 data = _show(repo_with_two_commits)
442 assert data.get("structured_delta") is not None, (
443 "structured_delta must be non-null on a commit with a parent"
444 )
445
446 def test_V2_structured_delta_null_on_initial_commit(
447 self, repo: pathlib.Path
448 ) -> None:
449 """V2: structured_delta is null on the initial commit (no parent to diff)."""
450 data = _show(repo)
451 # initial commit has no parent — structured_delta should be null
452 assert data["structured_delta"] is None, (
453 f"Initial commit structured_delta must be null, got {data['structured_delta']!r}"
454 )
455
456 def test_V3_no_delta_omits_key(self, repo_with_two_commits: pathlib.Path) -> None:
457 """V3: --no-delta removes the structured_delta key entirely."""
458 result = runner.invoke(
459 cli, ["read", "--json", "--no-delta"], env=_env(repo_with_two_commits)
460 )
461 assert result.exit_code == 0
462 data = json.loads(result.output.strip())
463 assert "structured_delta" not in data, (
464 "structured_delta must not appear with --no-delta"
465 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago