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