gabriel / muse public
test_log_json_schema.py python
402 lines 16.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for the canonical ``muse log --json`` schema.
2
3 Every commit object in the commits array must emit the same shape.
4 Agents rely on this stability — missing fields break provenance tracking
5 and force fragile ``dict.get`` guards.
6
7 Canonical schema
8 ----------------
9 ::
10
11 {
12 "truncated": bool,
13 "commits": [
14 {
15 "commit_id": str, // sha256:-prefixed
16 "created_on_branch": str,
17 "message": str,
18 "author": str, // "" when user.handle not configured
19 "agent_id": str, // "" when not an agent commit
20 "model_id": str, // "" when not an agent commit
21 "committed_at": str, // ISO-8601
22 "parent_commit_id": str | null, // sha256:-prefixed or null
23 "parent2_commit_id": str | null, // sha256:-prefixed or null (merge)
24 "snapshot_id": str | null, // sha256:-prefixed
25 "sem_ver_bump": str | null,
26 "breaking_changes": [str, ...],
27 "metadata": {str: ...},
28 "files_added": [str, ...], // always populated in --json mode
29 "files_removed": [str, ...], // always populated in --json mode
30 "files_modified": [str, ...] // always populated in --json mode
31 }
32 ]
33 }
34
35 Coverage matrix
36 ---------------
37 I Schema invariants (top-level shape)
38 I1 Top-level keys: truncated + commits always present
39 I2 Each commit has all required keys
40 I3 commit_id is sha256:-prefixed
41 I4 parent_commit_id is sha256:-prefixed or null
42 I5 snapshot_id is sha256:-prefixed
43
44 II File lists — always populated in --json mode, no --stat needed
45 II1 files_added populated for a commit that added a file
46 II2 files_modified populated for a commit that modified a file
47 II3 files_removed populated for a commit that deleted a file
48 II4 Initial commit: files_added non-empty, files_removed/modified empty
49
50 III Agent provenance fields
51 III1 agent_id present (empty string for non-agent commits)
52 III2 model_id present (empty string for non-agent commits)
53 III3 agent_id populated when --agent-id passed to commit
54 III4 model_id populated when --model-id passed to commit
55
56 IV Filters
57 IV1 --author filter returns only matching commits
58 IV2 --author filter is case-insensitive substring match
59 IV3 -n / --limit caps the number of commits returned
60 IV4 truncated=true when limit is hit
61 IV5 truncated=false when all commits fit
62
63 V Edge cases
64 V1 Single commit (initial): parent_commit_id is null
65 V2 Merge commit: parent2_commit_id is sha256:-prefixed (not null)
66 """
67
68 from __future__ import annotations
69 from collections.abc import Mapping
70
71 import json
72 import pathlib
73
74 import pytest
75
76 from tests.cli_test_helper import CliRunner
77
78 cli = None
79 runner = CliRunner()
80
81 _REQUIRED_COMMIT_KEYS = {
82 "commit_id", "created_on_branch", "message", "author",
83 "agent_id", "model_id",
84 "committed_at", "parent_commit_id", "parent2_commit_id",
85 "snapshot_id", "sem_ver_bump", "breaking_changes", "metadata",
86 "files_added", "files_removed", "files_modified",
87 }
88
89 _REQUIRED_TOP_KEYS = {"truncated", "commits"}
90
91
92 def _env(root: pathlib.Path) -> Mapping[str, str]:
93 return {"MUSE_REPO_ROOT": str(root)}
94
95
96 def _log_json(root: pathlib.Path, *extra_args: str) -> Mapping[str, object]:
97 result = runner.invoke(cli, ["log", "--json"] + list(extra_args), env=_env(root))
98 assert result.exit_code == 0, f"log --json failed: {result.output}"
99 return json.loads(result.output.strip())
100
101
102 @pytest.fixture()
103 def single_commit_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
104 """Code-domain repo with exactly one commit."""
105 monkeypatch.chdir(tmp_path)
106 result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
107 assert result.exit_code == 0, result.output
108 (tmp_path / "main.py").write_text("x = 1\n")
109 runner.invoke(cli, ["code", "add", "main.py"], env=_env(tmp_path))
110 result = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path))
111 assert result.exit_code == 0, result.output
112 return tmp_path
113
114
115 @pytest.fixture()
116 def multi_commit_repo(single_commit_repo: pathlib.Path) -> pathlib.Path:
117 """Repo with 3 commits: add, modify, delete."""
118 root = single_commit_repo
119 env = _env(root)
120
121 # Commit 2: modify main.py + add extra.py
122 (root / "main.py").write_text("x = 2\n")
123 (root / "extra.py").write_text("e = 1\n")
124 runner.invoke(cli, ["code", "add", "main.py", "extra.py"], env=env)
125 runner.invoke(cli, ["commit", "-m", "modify and add"], env=env)
126
127 # Commit 3: delete extra.py
128 (root / "extra.py").unlink()
129 runner.invoke(cli, ["code", "add", "extra.py"], env=env)
130 runner.invoke(cli, ["commit", "-m", "delete extra"], env=env)
131
132 return root
133
134
135 # ---------------------------------------------------------------------------
136 # I Schema invariants
137 # ---------------------------------------------------------------------------
138
139
140 class TestSchemaInvariantsI:
141 def test_I1_top_level_keys(self, single_commit_repo: pathlib.Path) -> None:
142 """I1: Top-level always has truncated + commits."""
143 data = _log_json(single_commit_repo)
144 assert _REQUIRED_TOP_KEYS.issubset(data.keys()), (
145 f"Missing top-level keys: {_REQUIRED_TOP_KEYS - data.keys()}"
146 )
147 assert isinstance(data["truncated"], bool)
148 assert isinstance(data["commits"], list)
149
150 def test_I2_each_commit_has_all_required_keys(self, single_commit_repo: pathlib.Path) -> None:
151 """I2: Every commit object has all required keys."""
152 data = _log_json(single_commit_repo)
153 assert len(data["commits"]) >= 1
154 for c in data["commits"]:
155 missing = _REQUIRED_COMMIT_KEYS - c.keys()
156 assert not missing, f"Commit missing keys: {missing}"
157
158 def test_I3_commit_id_is_sha256_prefixed(self, single_commit_repo: pathlib.Path) -> None:
159 """I3: commit_id is sha256:-prefixed."""
160 data = _log_json(single_commit_repo)
161 for c in data["commits"]:
162 assert c["commit_id"].startswith("sha256:"), (
163 f"commit_id must be sha256:-prefixed, got {c['commit_id']!r}"
164 )
165
166 def test_I4_parent_commit_id_is_sha256_prefixed_or_null(
167 self, multi_commit_repo: pathlib.Path
168 ) -> None:
169 """I4: parent_commit_id is sha256:-prefixed (non-null) or null (initial commit)."""
170 data = _log_json(multi_commit_repo)
171 commits = data["commits"]
172 # Most recent commits (non-initial) must have sha256:-prefixed parent
173 for c in commits[:-1]:
174 assert c["parent_commit_id"] is not None
175 assert c["parent_commit_id"].startswith("sha256:"), (
176 f"parent_commit_id must be sha256:-prefixed, got {c['parent_commit_id']!r}"
177 )
178 # Initial commit: parent is null
179 initial = commits[-1]
180 assert initial["parent_commit_id"] is None
181
182 def test_I5_snapshot_id_is_sha256_prefixed(self, single_commit_repo: pathlib.Path) -> None:
183 """I5: snapshot_id is sha256:-prefixed when present."""
184 data = _log_json(single_commit_repo)
185 for c in data["commits"]:
186 if c["snapshot_id"] is not None:
187 assert c["snapshot_id"].startswith("sha256:"), (
188 f"snapshot_id must be sha256:-prefixed, got {c['snapshot_id']!r}"
189 )
190
191
192 # ---------------------------------------------------------------------------
193 # II File lists — always populated in --json mode
194 # ---------------------------------------------------------------------------
195
196
197 class TestFileListsII:
198 def test_II1_files_added_populated_no_stat_flag(
199 self, single_commit_repo: pathlib.Path
200 ) -> None:
201 """II1: files_added populated in --json mode without --stat."""
202 data = _log_json(single_commit_repo)
203 # The initial commit added main.py
204 initial = data["commits"][-1]
205 assert "main.py" in initial["files_added"], (
206 f"Expected main.py in files_added, got {initial['files_added']}"
207 )
208
209 def test_II2_files_modified_populated(self, multi_commit_repo: pathlib.Path) -> None:
210 """II2: files_modified populated for a modify commit."""
211 data = _log_json(multi_commit_repo)
212 commits = data["commits"]
213 # Second-most-recent commit modified main.py (and added extra.py)
214 modify_commit = commits[1] # commits are newest-first
215 assert "main.py" in modify_commit["files_modified"], (
216 f"Expected main.py in files_modified, got {modify_commit}"
217 )
218
219 def test_II3_files_removed_populated(self, multi_commit_repo: pathlib.Path) -> None:
220 """II3: files_removed populated for a delete commit."""
221 data = _log_json(multi_commit_repo)
222 # Most recent commit deleted extra.py
223 delete_commit = data["commits"][0]
224 assert "extra.py" in delete_commit["files_removed"], (
225 f"Expected extra.py in files_removed, got {delete_commit}"
226 )
227
228 def test_II4_initial_commit_files_removed_and_modified_empty(
229 self, single_commit_repo: pathlib.Path
230 ) -> None:
231 """II4: Initial commit: files_removed and files_modified are empty lists."""
232 data = _log_json(single_commit_repo)
233 initial = data["commits"][-1]
234 assert initial["files_removed"] == []
235 assert initial["files_modified"] == []
236
237
238 # ---------------------------------------------------------------------------
239 # III Agent provenance fields
240 # ---------------------------------------------------------------------------
241
242
243 class TestAgentProvenanceIII:
244 def test_III1_agent_id_present_empty_for_non_agent_commit(
245 self, single_commit_repo: pathlib.Path
246 ) -> None:
247 """III1: agent_id is always present; empty string for non-agent commits."""
248 data = _log_json(single_commit_repo)
249 for c in data["commits"]:
250 assert "agent_id" in c, "agent_id must always be present"
251 assert isinstance(c["agent_id"], str)
252
253 def test_III2_model_id_present_empty_for_non_agent_commit(
254 self, single_commit_repo: pathlib.Path
255 ) -> None:
256 """III2: model_id is always present; empty string for non-agent commits."""
257 data = _log_json(single_commit_repo)
258 for c in data["commits"]:
259 assert "model_id" in c, "model_id must always be present"
260 assert isinstance(c["model_id"], str)
261
262 def test_III3_agent_id_populated_when_passed_to_commit(
263 self, single_commit_repo: pathlib.Path
264 ) -> None:
265 """III3: agent_id reflects --agent-id passed at commit time."""
266 root = single_commit_repo
267 env = _env(root)
268 (root / "agent_file.py").write_text("a = 1\n")
269 runner.invoke(cli, ["code", "add", "agent_file.py"], env=env)
270 result = runner.invoke(
271 cli,
272 ["commit", "-m", "agent commit", "--agent-id", "test-agent-42"],
273 env=env,
274 )
275 assert result.exit_code == 0, result.output
276
277 data = _log_json(root, "-n", "1")
278 c = data["commits"][0]
279 assert c["agent_id"] == "test-agent-42", (
280 f"Expected agent_id='test-agent-42', got {c['agent_id']!r}"
281 )
282
283 def test_III4_model_id_populated_when_passed_to_commit(
284 self, single_commit_repo: pathlib.Path
285 ) -> None:
286 """III4: model_id reflects --model-id passed at commit time."""
287 root = single_commit_repo
288 env = _env(root)
289 (root / "model_file.py").write_text("m = 1\n")
290 runner.invoke(cli, ["code", "add", "model_file.py"], env=env)
291 result = runner.invoke(
292 cli,
293 ["commit", "-m", "model commit", "--model-id", "claude-sonnet-4-6"],
294 env=env,
295 )
296 assert result.exit_code == 0, result.output
297
298 data = _log_json(root, "-n", "1")
299 c = data["commits"][0]
300 assert c["model_id"] == "claude-sonnet-4-6", (
301 f"Expected model_id='claude-sonnet-4-6', got {c['model_id']!r}"
302 )
303
304
305 # ---------------------------------------------------------------------------
306 # IV Filters
307 # ---------------------------------------------------------------------------
308
309
310 class TestFiltersIV:
311 def test_IV1_author_filter_matches_commits(
312 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
313 ) -> None:
314 """IV1: --author filter returns only commits matching the author."""
315 monkeypatch.chdir(tmp_path)
316 env = _env(tmp_path)
317 runner.invoke(cli, ["init", "--domain", "code"], env=env)
318 (tmp_path / "a.py").write_text("a\n")
319 runner.invoke(cli, ["code", "add", "a.py"], env=env)
320 runner.invoke(cli, ["commit", "-m", "gabriel commit", "--author", "gabriel"], env=env)
321
322 (tmp_path / "b.py").write_text("b\n")
323 runner.invoke(cli, ["code", "add", "b.py"], env=env)
324 runner.invoke(cli, ["commit", "-m", "agent commit", "--author", "bot-agent"], env=env)
325
326 data = _log_json(tmp_path, "--author", "gabriel")
327 assert all("gabriel" in c["author"].lower() for c in data["commits"]), (
328 f"--author filter returned non-matching commits: {[c['author'] for c in data['commits']]}"
329 )
330 assert not any(c["author"] == "bot-agent" for c in data["commits"])
331
332 def test_IV2_author_filter_is_case_insensitive(
333 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
334 ) -> None:
335 """IV2: --author filter is a case-insensitive substring match."""
336 monkeypatch.chdir(tmp_path)
337 env = _env(tmp_path)
338 runner.invoke(cli, ["init", "--domain", "code"], env=env)
339 (tmp_path / "x.py").write_text("x\n")
340 runner.invoke(cli, ["code", "add", "x.py"], env=env)
341 runner.invoke(cli, ["commit", "-m", "msg", "--author", "Gabriel"], env=env)
342
343 data_lower = _log_json(tmp_path, "--author", "gabriel")
344 data_upper = _log_json(tmp_path, "--author", "GABRIEL")
345 assert len(data_lower["commits"]) == len(data_upper["commits"])
346
347 def test_IV3_limit_caps_commits(self, multi_commit_repo: pathlib.Path) -> None:
348 """IV3: -n caps the number of commits returned."""
349 data = _log_json(multi_commit_repo, "-n", "1")
350 assert len(data["commits"]) == 1
351
352 def test_IV4_truncated_true_when_limit_hit(self, multi_commit_repo: pathlib.Path) -> None:
353 """IV4: truncated=true when -n limit is reached before exhausting history."""
354 data = _log_json(multi_commit_repo, "-n", "1")
355 assert data["truncated"] is True
356
357 def test_IV5_truncated_false_when_all_fit(self, single_commit_repo: pathlib.Path) -> None:
358 """IV5: truncated=false when limit is not reached (all commits returned)."""
359 data = _log_json(single_commit_repo)
360 assert data["truncated"] is False
361
362
363 # ---------------------------------------------------------------------------
364 # V Edge cases
365 # ---------------------------------------------------------------------------
366
367
368 class TestEdgeCasesV:
369 def test_V1_initial_commit_parent_is_null(self, single_commit_repo: pathlib.Path) -> None:
370 """V1: Initial commit has parent_commit_id=null and parent2_commit_id=null."""
371 data = _log_json(single_commit_repo)
372 initial = data["commits"][-1]
373 assert initial["parent_commit_id"] is None
374 assert initial["parent2_commit_id"] is None
375
376 def test_V2_merge_commit_has_two_parents(
377 self, single_commit_repo: pathlib.Path
378 ) -> None:
379 """V2: Merge commit has both parent_commit_id and parent2_commit_id set."""
380 root = single_commit_repo
381 env = _env(root)
382
383 # Create and commit on a feature branch
384 runner.invoke(cli, ["checkout", "-b", "feat/test"], env=env)
385 (root / "feat.py").write_text("f = 1\n")
386 runner.invoke(cli, ["code", "add", "feat.py"], env=env)
387 runner.invoke(cli, ["commit", "-m", "feat commit"], env=env)
388
389 # Merge back into main — use --no-ff to force a merge commit
390 # (a fast-forward would just move the pointer, creating no merge commit).
391 runner.invoke(cli, ["checkout", "main"], env=env)
392 merge_result = runner.invoke(cli, ["merge", "--no-ff", "feat/test"], env=env)
393 assert merge_result.exit_code == 0, merge_result.output
394
395 data = _log_json(root, "-n", "1")
396 merge_commit = data["commits"][0]
397 assert merge_commit["parent2_commit_id"] is not None, (
398 "Merge commit must have parent2_commit_id set"
399 )
400 assert merge_commit["parent2_commit_id"].startswith("sha256:"), (
401 f"parent2_commit_id must be sha256:-prefixed, got {merge_commit['parent2_commit_id']!r}"
402 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago