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