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