gabriel / muse public
test_commit_json_schema.py python
461 lines 19.1 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Tests for the canonical ``muse commit --json`` schema.
2
3 ``muse commit`` is the core write operation — every agent pipeline ends here.
4 The JSON output must expose all provenance fields so downstream consumers
5 (hub, orchestrators, other agents) never need a follow-up ``muse read`` just
6 to discover what model produced a commit.
7
8 Canonical schema (success path)
9 ---------------------------------
10 ::
11
12 {
13 "dry_run": false,
14 "commit_id": "sha256:<64-hex>",
15 "branch": str,
16 "snapshot_id": str,
17 "message": str,
18 "parent_commit_id": str | null,
19 "parent2_commit_id": str | null,
20 "committed_at": str, // ISO 8601 with timezone
21 "author": str,
22 "agent_id": str, // "" for human commits
23 "model_id": str, // "" for human commits
24 "toolchain_id": str,
25 "sem_ver_bump": str, // "none" | "patch" | "minor" | "major"
26 "breaking_changes": [str, ...],
27 "files_changed": {
28 "added": int,
29 "modified": int,
30 "deleted": int,
31 "total": int // added + modified + deleted
32 }
33 }
34
35 Dry-run schema is identical except ``dry_run`` is ``true`` and ``clean`` may
36 appear when the working tree has no changes.
37
38 Coverage
39 --------
40 I Schema invariants
41 I1 All required keys present on a normal commit
42 I2 commit_id is sha256:-prefixed
43 I3 committed_at is ISO 8601 with timezone
44 I4 sem_ver_bump is a valid enum value
45 I5 breaking_changes is always a list
46 I6 files_changed has added, modified, deleted, total keys
47 I7 files_changed.total = added + modified + deleted
48
49 II Agent provenance in commit output
50 II1 agent_id populated from --agent-id flag
51 II2 model_id populated from --model-id flag
52 II3 toolchain_id populated from --toolchain-id flag
53 II4 agent_id empty string (not null) for human commits
54 II5 model_id empty string (not null) for human commits
55 II6 model_id from MUSE_MODEL_ID env when flag absent
56 II7 toolchain_id from MUSE_TOOLCHAIN_ID env when flag absent
57 II8 --agent-id flag overrides MUSE_AGENT_ID env
58
59 III Dry-run schema parity
60 III1 dry_run schema has same required keys as success path (minus clean)
61 III2 dry_run: true in dry-run output
62 III3 dry_run: false in normal commit output
63 III4 dry-run output has model_id and toolchain_id
64 III5 dry-run clean tree exits 1 with clean=true JSON
65
66 IV File change accounting
67 IV1 Initial commit files_changed.added >= 1
68 IV2 Modification increments modified, not added
69 IV3 Deletion increments deleted
70 IV4 files_changed.total = added + modified + deleted always
71
72 V Error paths (JSON mode)
73 V1 Missing -m exits 1 with JSON {"error": "no_message", ...}
74 V2 Empty workdir exits 1 with JSON {"error": "empty_workdir", ...}
75 V3 Clean tree (no --dry-run) exits 0, no JSON output (text mode behaviour)
76 """
77
78 from __future__ import annotations
79
80 import json
81 import os
82 import pathlib
83
84 import pytest
85
86 from tests.cli_test_helper import CliRunner
87
88 cli = None
89 runner = CliRunner()
90
91 _REQUIRED_KEYS = {
92 "dry_run",
93 "commit_id", "branch", "snapshot_id",
94 "message", "parent_commit_id", "parent2_commit_id",
95 "committed_at", "author",
96 "agent_id", "model_id", "toolchain_id",
97 "sem_ver_bump", "breaking_changes",
98 "files_changed",
99 }
100
101 _FILES_CHANGED_KEYS = {"added", "modified", "deleted", "total"}
102 _VALID_SEM_VER_BUMPS = {"none", "patch", "minor", "major"}
103
104
105 def _env(root: pathlib.Path) -> dict[str, str]:
106 return {"MUSE_REPO_ROOT": str(root)}
107
108
109 def _commit(root: pathlib.Path, *flags: str, env: dict | None = None) -> dict:
110 e = {**_env(root), **(env or {})}
111 result = runner.invoke(cli, ["commit", "--json"] + list(flags), env=e)
112 assert result.exit_code == 0, f"commit --json failed (exit {result.exit_code}):\n{result.output}"
113 return json.loads(result.output.strip())
114
115
116 def _commit_raw(root: pathlib.Path, *args: str, env: dict | None = None):
117 e = {**_env(root), **(env or {})}
118 return runner.invoke(cli, ["commit", "--json"] + list(args), env=e)
119
120
121 @pytest.fixture()
122 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
123 """Code-domain repo initialised but with nothing committed yet."""
124 monkeypatch.chdir(tmp_path)
125 env = _env(tmp_path)
126 result = runner.invoke(cli, ["init", "--domain", "code"], env=env)
127 assert result.exit_code == 0, result.output
128 (tmp_path / "module.py").write_text("def greet():\n return 'hello'\n")
129 runner.invoke(cli, ["code", "add", "module.py"], env=env)
130 return tmp_path
131
132
133 @pytest.fixture()
134 def committed_repo(
135 repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
136 ) -> pathlib.Path:
137 """Code-domain repo with one commit already made."""
138 env = _env(repo)
139 result = runner.invoke(cli, ["commit", "-m", "initial"], env=env)
140 assert result.exit_code == 0, result.output
141 return repo
142
143
144 # ---------------------------------------------------------------------------
145 # I Schema invariants
146 # ---------------------------------------------------------------------------
147
148
149 class TestSchemaInvariantsI:
150 def test_I1_all_required_keys_present(self, repo: pathlib.Path) -> None:
151 """I1: Every required key must be present in commit --json output."""
152 data = _commit(repo, "-m", "initial commit")
153 missing = _REQUIRED_KEYS - data.keys()
154 assert not missing, f"Missing required keys in commit --json: {missing}"
155
156 def test_I2_commit_id_sha256_prefixed(self, repo: pathlib.Path) -> None:
157 """I2: commit_id must start with 'sha256:'."""
158 data = _commit(repo, "-m", "initial commit")
159 assert data["commit_id"].startswith("sha256:"), (
160 f"commit_id must be sha256:-prefixed, got {data['commit_id']!r}"
161 )
162
163 def test_I3_committed_at_is_iso8601_with_tz(self, repo: pathlib.Path) -> None:
164 """I3: committed_at must parse as ISO 8601 with timezone info."""
165 import datetime
166 data = _commit(repo, "-m", "initial")
167 dt = datetime.datetime.fromisoformat(data["committed_at"])
168 assert dt.tzinfo is not None, (
169 f"committed_at lacks timezone: {data['committed_at']!r}"
170 )
171
172 def test_I4_sem_ver_bump_valid_enum(self, repo: pathlib.Path) -> None:
173 """I4: sem_ver_bump must be one of the four valid values."""
174 data = _commit(repo, "-m", "initial")
175 assert data["sem_ver_bump"] in _VALID_SEM_VER_BUMPS, (
176 f"sem_ver_bump {data['sem_ver_bump']!r} not in {_VALID_SEM_VER_BUMPS}"
177 )
178
179 def test_I5_breaking_changes_always_list(self, repo: pathlib.Path) -> None:
180 """I5: breaking_changes is always a list (never null or absent)."""
181 data = _commit(repo, "-m", "initial")
182 assert isinstance(data["breaking_changes"], list), (
183 f"breaking_changes must be list, got {type(data['breaking_changes'])}"
184 )
185
186 def test_I6_files_changed_has_all_keys(self, repo: pathlib.Path) -> None:
187 """I6: files_changed must have added, modified, deleted, and total keys."""
188 data = _commit(repo, "-m", "initial")
189 fc = data["files_changed"]
190 missing = _FILES_CHANGED_KEYS - fc.keys()
191 assert not missing, (
192 f"files_changed missing keys: {missing}. Got: {fc}"
193 )
194
195 def test_I7_files_changed_total_is_sum(self, repo: pathlib.Path) -> None:
196 """I7: files_changed.total = added + modified + deleted."""
197 data = _commit(repo, "-m", "initial")
198 fc = data["files_changed"]
199 expected = fc["added"] + fc["modified"] + fc["deleted"]
200 assert fc["total"] == expected, (
201 f"files_changed.total {fc['total']} != "
202 f"added({fc['added']}) + modified({fc['modified']}) + deleted({fc['deleted']}) = {expected}"
203 )
204
205
206 # ---------------------------------------------------------------------------
207 # II Agent provenance in commit output
208 # ---------------------------------------------------------------------------
209
210
211 class TestAgentProvenanceII:
212 def test_II1_agent_id_in_output(self, repo: pathlib.Path) -> None:
213 """II1: agent_id from --agent-id appears in JSON output."""
214 data = _commit(repo, "-m", "bot commit", "--agent-id", "test-bot")
215 assert data["agent_id"] == "test-bot", (
216 f"Expected agent_id='test-bot', got {data['agent_id']!r}"
217 )
218
219 def test_II2_model_id_in_output(self, repo: pathlib.Path) -> None:
220 """II2: model_id from --model-id appears in JSON output."""
221 data = _commit(repo, "-m", "model commit", "--model-id", "claude-opus-4")
222 assert data["model_id"] == "claude-opus-4", (
223 f"Expected model_id='claude-opus-4', got {data['model_id']!r}"
224 )
225
226 def test_II3_toolchain_id_in_output(self, repo: pathlib.Path) -> None:
227 """II3: toolchain_id from --toolchain-id appears in JSON output."""
228 data = _commit(repo, "-m", "tc commit", "--toolchain-id", "cursor-v2")
229 assert data["toolchain_id"] == "cursor-v2", (
230 f"Expected toolchain_id='cursor-v2', got {data['toolchain_id']!r}"
231 )
232
233 def test_II4_agent_id_empty_string_for_human(self, repo: pathlib.Path) -> None:
234 """II4: agent_id is '' (not null) for human commits."""
235 data = _commit(repo, "-m", "human commit")
236 assert data["agent_id"] == "", (
237 f"agent_id must be '' for human commit, got {data['agent_id']!r}"
238 )
239
240 def test_II5_model_id_empty_string_for_human(self, repo: pathlib.Path) -> None:
241 """II5: model_id is '' (not null) for human commits."""
242 data = _commit(repo, "-m", "human commit")
243 assert data["model_id"] == "", (
244 f"model_id must be '' for human commit, got {data['model_id']!r}"
245 )
246
247 def test_II6_model_id_from_env(
248 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
249 ) -> None:
250 """II6: model_id picked up from MUSE_MODEL_ID env when --model-id absent."""
251 env = {**_env(repo), "MUSE_MODEL_ID": "claude-haiku-4"}
252 data = _commit(repo, "-m", "env model", env=env)
253 assert data["model_id"] == "claude-haiku-4", (
254 f"Expected model_id='claude-haiku-4' from env, got {data['model_id']!r}"
255 )
256
257 def test_II7_toolchain_id_from_env(
258 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
259 ) -> None:
260 """II7: toolchain_id from MUSE_TOOLCHAIN_ID when --toolchain-id absent."""
261 env = {**_env(repo), "MUSE_TOOLCHAIN_ID": "agentic-v3"}
262 data = _commit(repo, "-m", "env tc", env=env)
263 assert data["toolchain_id"] == "agentic-v3", (
264 f"Expected toolchain_id='agentic-v3' from env, got {data['toolchain_id']!r}"
265 )
266
267 def test_II8_flag_overrides_env_for_agent_id(
268 self, repo: pathlib.Path
269 ) -> None:
270 """II8: --agent-id flag takes priority over MUSE_AGENT_ID env."""
271 env = {**_env(repo), "MUSE_AGENT_ID": "env-bot"}
272 data = _commit(repo, "-m", "override", "--agent-id", "flag-bot", env=env)
273 assert data["agent_id"] == "flag-bot", (
274 f"Expected flag-bot to override env-bot, got {data['agent_id']!r}"
275 )
276
277
278 # ---------------------------------------------------------------------------
279 # III Dry-run schema parity
280 # ---------------------------------------------------------------------------
281
282
283 class TestDryRunSchemaIII:
284 def test_III1_dry_run_has_same_required_keys(self, repo: pathlib.Path) -> None:
285 """III1: dry-run output has the same required keys as the success path."""
286 result = _commit_raw(repo, "-m", "check", "--dry-run")
287 assert result.exit_code == 0, f"dry-run failed:\n{result.output}"
288 data = json.loads(result.output.strip())
289 missing = _REQUIRED_KEYS - data.keys()
290 assert not missing, f"dry-run missing required keys: {missing}"
291
292 def test_III2_dry_run_flag_is_true(self, repo: pathlib.Path) -> None:
293 """III2: dry_run=true in dry-run output."""
294 result = _commit_raw(repo, "-m", "check", "--dry-run")
295 assert result.exit_code == 0
296 data = json.loads(result.output.strip())
297 assert data["dry_run"] is True
298
299 def test_III3_dry_run_false_on_real_commit(self, repo: pathlib.Path) -> None:
300 """III3: dry_run=false in normal commit output."""
301 data = _commit(repo, "-m", "real commit")
302 assert data["dry_run"] is False
303
304 def test_III4_dry_run_has_model_id_and_toolchain_id(
305 self, repo: pathlib.Path
306 ) -> None:
307 """III4: dry-run output includes model_id and toolchain_id."""
308 result = _commit_raw(
309 repo, "-m", "preflight",
310 "--dry-run", "--model-id", "claude-opus-4", "--toolchain-id", "cursor",
311 )
312 assert result.exit_code == 0
313 data = json.loads(result.output.strip())
314 assert data["model_id"] == "claude-opus-4", (
315 f"model_id missing from dry-run output: {data}"
316 )
317 assert data["toolchain_id"] == "cursor", (
318 f"toolchain_id missing from dry-run output: {data}"
319 )
320
321 def test_III5_dry_run_clean_tree_exits_1(
322 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
323 ) -> None:
324 """III5: dry-run on a clean tree exits 1 with clean=true in JSON.
325
326 Uses its own repo to ensure a truly clean workdir (all files committed).
327 muse init --domain code creates .museattributes/.museignore, so we commit
328 everything once first to establish HEAD == workdir, then dry-run.
329 """
330 monkeypatch.chdir(tmp_path)
331 env = _env(tmp_path)
332 runner.invoke(cli, ["init", "--domain", "code"], env=env)
333 (tmp_path / "module.py").write_text("x = 1\n")
334 # Commit everything so HEAD == workdir (includes init-created files)
335 result = runner.invoke(cli, ["commit", "-m", "initial"], env=env)
336 assert result.exit_code == 0, result.output
337 # Now dry-run should detect nothing to commit
338 result = _commit_raw(tmp_path, "-m", "nothing", "--dry-run", env=env)
339 assert result.exit_code == 1, (
340 f"Expected exit 1 for dry-run on clean tree, got {result.exit_code}. "
341 f"Output: {result.output}"
342 )
343 data = json.loads(result.output.strip())
344 assert data.get("clean") is True, (
345 f"Expected clean=true in dry-run clean-tree JSON: {data}"
346 )
347 assert data.get("dry_run") is True
348
349
350 # ---------------------------------------------------------------------------
351 # IV File change accounting
352 # ---------------------------------------------------------------------------
353
354
355 class TestFileChangeAccountingIV:
356 def test_IV1_initial_commit_added_gte_1(self, repo: pathlib.Path) -> None:
357 """IV1: Initial commit adds at least the tracked file."""
358 data = _commit(repo, "-m", "initial")
359 assert data["files_changed"]["added"] >= 1, (
360 f"Initial commit should add >=1 file: {data['files_changed']}"
361 )
362
363 def test_IV2_modification_increments_modified(
364 self, committed_repo: pathlib.Path
365 ) -> None:
366 """IV2: Editing an existing file increments modified, not added."""
367 env = _env(committed_repo)
368 (committed_repo / "module.py").write_text("def greet():\n return 'hi'\n")
369 runner.invoke(cli, ["code", "add", "module.py"], env=env)
370 data = _commit(committed_repo, "-m", "modify")
371 assert data["files_changed"]["modified"] == 1
372 assert data["files_changed"]["added"] == 0
373
374 def test_IV3_deletion_increments_deleted(
375 self, committed_repo: pathlib.Path
376 ) -> None:
377 """IV3: Removing a tracked file increments deleted.
378
379 Uses a second file so deleting one doesn't leave an empty workdir
380 (an empty manifest triggers "empty workdir" rather than a deletion).
381 """
382 env = _env(committed_repo)
383 # Add a second file so there's still something tracked after the deletion.
384 (committed_repo / "extra.py").write_text("y = 2\n")
385 runner.invoke(cli, ["code", "add", "extra.py"], env=env)
386 runner.invoke(cli, ["commit", "-m", "add extra"], env=env)
387 # Now delete extra.py — module.py remains, so workdir is non-empty.
388 (committed_repo / "extra.py").unlink()
389 runner.invoke(cli, ["code", "add", "extra.py"], env=env)
390 data = _commit(committed_repo, "-m", "remove extra")
391 assert data["files_changed"]["deleted"] == 1
392 assert data["files_changed"]["added"] == 0
393
394 def test_IV4_total_always_matches_sum(
395 self, committed_repo: pathlib.Path
396 ) -> None:
397 """IV4: files_changed.total = added + modified + deleted, always."""
398 env = _env(committed_repo)
399 (committed_repo / "new.py").write_text("x = 1\n")
400 (committed_repo / "module.py").write_text("def greet():\n return 'hi'\n")
401 runner.invoke(cli, ["code", "add", "new.py"], env=env)
402 runner.invoke(cli, ["code", "add", "module.py"], env=env)
403 data = _commit(committed_repo, "-m", "mixed")
404 fc = data["files_changed"]
405 expected = fc["added"] + fc["modified"] + fc["deleted"]
406 assert fc["total"] == expected, (
407 f"total {fc['total']} != sum {expected}: {fc}"
408 )
409
410
411 # ---------------------------------------------------------------------------
412 # V Error paths
413 # ---------------------------------------------------------------------------
414
415
416 class TestErrorPathsV:
417 def test_V1_missing_message_exits_1_with_json_error(
418 self, repo: pathlib.Path
419 ) -> None:
420 """V1: Missing -m exits 1 with JSON error {"error": "no_message"}."""
421 result = _commit_raw(repo) # no -m
422 assert result.exit_code == 1
423 json_line = next(
424 (l for l in result.output.strip().splitlines() if l.startswith("{")),
425 None,
426 )
427 assert json_line is not None, f"No JSON in output: {result.output!r}"
428 data = json.loads(json_line)
429 assert data["error"] == "no_message", (
430 f"Expected error='no_message', got {data.get('error')!r}"
431 )
432
433 def test_V2_clean_tree_json_response(
434 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
435 ) -> None:
436 """V2: --json on a clean tree (no --dry-run) exits 0 with clean=true JSON.
437
438 An agent using ``muse commit --json -m "msg"`` on a clean repo must get
439 a machine-readable response — not a silent text-only "Nothing to commit".
440 """
441 monkeypatch.chdir(tmp_path)
442 env = _env(tmp_path)
443 runner.invoke(cli, ["init", "--domain", "code"], env=env)
444 (tmp_path / "module.py").write_text("x = 1\n")
445 # Commit everything to establish HEAD == workdir
446 result = runner.invoke(cli, ["commit", "-m", "initial"], env=env)
447 assert result.exit_code == 0, result.output
448 # Second commit on clean tree — must return JSON
449 result = _commit_raw(tmp_path, "-m", "nothing", env=env)
450 assert result.exit_code == 0
451 json_line = next(
452 (l for l in result.output.strip().splitlines() if l.startswith("{")),
453 None,
454 )
455 assert json_line is not None, (
456 f"No JSON on stdout for clean-tree --json commit: {result.output!r}"
457 )
458 data = json.loads(json_line)
459 assert data.get("clean") is True, (
460 f"Expected clean=true in clean-tree commit JSON: {data}"
461 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago