gabriel / muse public
test_status_json_schema.py python
399 lines 15.4 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Tests for the canonical ``muse status --json`` schema.
2
3 Every code path that produces ``muse status --json`` output must emit the
4 *same* shape. Agents rely on this stability — a schema that changes
5 depending on whether a stage index is present is a latent bug.
6
7 Canonical schema
8 ----------------
9 ::
10
11 {
12 "branch": str,
13 "head_commit": str | null,
14 "upstream": str | null,
15 "ahead": int | null,
16 "behind": int | null,
17 "clean": bool,
18 "dirty": bool,
19 "total_changes": int,
20
21 // Flat view — always populated, union of staged + unstaged.
22 // Primary interface: agents that only need "what changed" use these.
23 "added": [str, ...],
24 "modified": [str, ...],
25 "deleted": [str, ...],
26 "renamed": {str: str, ...},
27
28 // Staging detail — null when domain has no staging concept.
29 // When non-null, partitions the flat view.
30 "staged": {
31 "added": [str, ...],
32 "modified": [str, ...],
33 "deleted": [str, ...]
34 } | null,
35 "unstaged": {
36 "added": [str, ...],
37 "modified": [str, ...],
38 "deleted": [str, ...]
39 } | null,
40
41 // Files on disk but not tracked by Muse. Always [] for non-code domains.
42 "untracked": [str, ...],
43
44 // Merge state — always present.
45 "conflict_paths": [str, ...],
46 "merge_in_progress": bool,
47 "merge_from": str | null,
48 "conflict_count": int,
49 "checkout_interrupted": bool,
50 "checkout_target": str | null
51 }
52
53 Coverage matrix
54 ---------------
55 I Schema invariants (always-present keys, correct types)
56 I1 Clean repo — all present, all empty/false
57 I2 Code domain with staged changes — same keys, staged non-null
58 I3 Code domain with unstaged changes — staged sub-obj still present
59 I4 Code domain with both staged and unstaged — both sub-objs populated
60 I5 Code domain with untracked files — untracked list populated
61
62 II Flat view correctness
63 II1 added = staged.added ∪ unstaged.added
64 II2 modified = staged.modified ∪ unstaged.modified
65 II3 deleted = staged.deleted ∪ unstaged.deleted
66 II4 total_changes = len(added) + len(modified) + len(deleted) + len(renamed)
67 II5 File in both staged and unstaged appears once in flat view
68
69 III Stage-domain vs no-stage-domain
70 III1 Code domain (stage): staged and unstaged are dicts, not null
71 III2 No stage index present: staged and unstaged are null (non-staged run)
72
73 IV Specific field values
74 IV1 branch matches current branch
75 IV2 head_commit is sha256:-prefixed
76 IV3 clean=True only when no changes
77 IV4 dirty = not clean, always
78 """
79
80 from __future__ import annotations
81
82 import json
83 import pathlib
84
85 import pytest
86
87 from tests.cli_test_helper import CliRunner
88
89 cli = None
90 runner = CliRunner()
91
92 # ---------------------------------------------------------------------------
93 # Helpers
94 # ---------------------------------------------------------------------------
95
96 _REQUIRED_TOP_KEYS = {
97 "branch", "head_commit", "upstream", "ahead", "behind",
98 "clean", "dirty", "total_changes",
99 "added", "modified", "deleted", "renamed",
100 "staged", "unstaged", "untracked",
101 "conflict_paths", "merge_in_progress", "merge_from",
102 "conflict_count", "checkout_interrupted", "checkout_target",
103 }
104
105 _STAGED_BUCKET_KEYS = {"added", "modified", "deleted"}
106
107
108 def _env(root: pathlib.Path) -> dict[str, str]:
109 return {"MUSE_REPO_ROOT": str(root)}
110
111
112 def _status_json(root: pathlib.Path) -> dict:
113 result = runner.invoke(cli, ["status", "--json"], env=_env(root))
114 assert result.exit_code == 0, f"status --json failed: {result.output}"
115 return json.loads(result.output.strip())
116
117
118 @pytest.fixture()
119 def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
120 """Minimal code-domain repo with one committed file."""
121 monkeypatch.chdir(tmp_path)
122 result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
123 assert result.exit_code == 0, result.output
124 (tmp_path / "main.py").write_text("x = 1\n")
125 runner.invoke(cli, ["code", "add", "main.py"], env=_env(tmp_path))
126 result = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path))
127 assert result.exit_code == 0, result.output
128 return tmp_path
129
130
131 # ---------------------------------------------------------------------------
132 # I Schema invariants
133 # ---------------------------------------------------------------------------
134
135
136 class TestSchemaInvariantsI:
137 def test_I1_clean_repo_all_required_keys_present(self, code_repo: pathlib.Path) -> None:
138 """I1: Clean repo — every required key is present with correct type."""
139 root = code_repo
140 data = _status_json(root)
141
142 assert _REQUIRED_TOP_KEYS.issubset(data.keys()), (
143 f"Missing keys: {_REQUIRED_TOP_KEYS - data.keys()}"
144 )
145 assert data["clean"] is True
146 assert data["dirty"] is False
147 assert data["added"] == []
148 assert data["modified"] == []
149 assert data["deleted"] == []
150 assert data["renamed"] == {}
151 assert isinstance(data["untracked"], list) # present, may contain init files
152 assert data["total_changes"] == 0
153 assert data["conflict_paths"] == []
154 assert data["merge_in_progress"] is False
155 assert data["merge_from"] is None
156 assert data["conflict_count"] == 0
157
158 def test_I2_staged_file_schema_unchanged(self, code_repo: pathlib.Path) -> None:
159 """I2: Staged changes — all top-level keys present, staged is a dict not null."""
160 root = code_repo
161 (root / "main.py").write_text("x = 2\n")
162 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
163
164 data = _status_json(root)
165
166 assert _REQUIRED_TOP_KEYS.issubset(data.keys()), (
167 f"Missing keys: {_REQUIRED_TOP_KEYS - data.keys()}"
168 )
169 assert data["staged"] is not None
170 assert data["unstaged"] is not None
171 assert _STAGED_BUCKET_KEYS == set(data["staged"].keys()), (
172 f"staged sub-object has wrong keys: {data['staged'].keys()}"
173 )
174 assert _STAGED_BUCKET_KEYS == set(data["unstaged"].keys()), (
175 f"unstaged sub-object has wrong keys: {data['unstaged'].keys()}"
176 )
177
178 def test_I3_unstaged_file_schema_unchanged(self, code_repo: pathlib.Path) -> None:
179 """I3: Unstaged changes only (nothing staged) — staged sub-obj still present."""
180 root = code_repo
181 # Modify file but do NOT stage it
182 (root / "main.py").write_text("x = 3\n")
183
184 data = _status_json(root)
185
186 assert _REQUIRED_TOP_KEYS.issubset(data.keys())
187 # staged and unstaged must be present even when nothing is staged
188 assert data["staged"] is not None
189 assert data["unstaged"] is not None
190 assert _STAGED_BUCKET_KEYS == set(data["staged"].keys())
191 assert _STAGED_BUCKET_KEYS == set(data["unstaged"].keys())
192
193 def test_I4_both_staged_and_unstaged(self, code_repo: pathlib.Path) -> None:
194 """I4: Both staged and unstaged changes — both sub-objs populated."""
195 root = code_repo
196 # Stage main.py modification
197 (root / "main.py").write_text("x = 2\n")
198 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
199 # Then modify it again (now staged M + unstaged M)
200 (root / "main.py").write_text("x = 3\n")
201 # Also add a new file unstaged
202 (root / "other.py").write_text("y = 1\n")
203
204 data = _status_json(root)
205
206 assert _REQUIRED_TOP_KEYS.issubset(data.keys())
207 assert data["staged"] is not None
208 assert data["unstaged"] is not None
209 assert data["dirty"] is True
210
211 def test_I5_untracked_files_in_list(self, code_repo: pathlib.Path) -> None:
212 """I5: Untracked files appear in untracked list, not in added."""
213 root = code_repo
214 (root / "brand_new.py").write_text("# not staged\n")
215
216 data = _status_json(root)
217
218 assert "brand_new.py" in data["untracked"]
219 # Untracked (not staged) must NOT appear in flat added
220 assert "brand_new.py" not in data["added"]
221
222
223 # ---------------------------------------------------------------------------
224 # II Flat view correctness
225 # ---------------------------------------------------------------------------
226
227
228 class TestFlatViewCorrectnessII:
229 def test_II1_flat_added_is_union_of_staged_and_unstaged(
230 self, code_repo: pathlib.Path
231 ) -> None:
232 """II1: flat added = staged.added ∪ unstaged.added."""
233 root = code_repo
234 (root / "new_a.py").write_text("a\n")
235 (root / "new_b.py").write_text("b\n")
236 runner.invoke(cli, ["code", "add", "new_a.py"], env=_env(root))
237 # new_b.py is untracked, not in either bucket
238
239 data = _status_json(root)
240
241 flat_added = set(data["added"])
242 staged_added = set(data["staged"]["added"])
243 unstaged_added = set(data["unstaged"]["added"])
244 assert flat_added == staged_added | unstaged_added
245
246 def test_II2_flat_modified_is_union_of_staged_and_unstaged(
247 self, code_repo: pathlib.Path
248 ) -> None:
249 """II2: flat modified = staged.modified ∪ unstaged.modified."""
250 root = code_repo
251 (root / "extra.py").write_text("e = 1\n")
252 runner.invoke(cli, ["code", "add", "extra.py"], env=_env(root))
253 runner.invoke(cli, ["commit", "-m", "add extra"], env=_env(root))
254
255 # Stage main.py modification
256 (root / "main.py").write_text("x = 2\n")
257 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
258 # Unstaged: modify extra.py
259 (root / "extra.py").write_text("e = 99\n")
260
261 data = _status_json(root)
262
263 flat_modified = set(data["modified"])
264 staged_modified = set(data["staged"]["modified"])
265 unstaged_modified = set(data["unstaged"]["modified"])
266 assert flat_modified == staged_modified | unstaged_modified
267 assert "main.py" in staged_modified
268 assert "extra.py" in unstaged_modified
269
270 def test_II3_flat_deleted_is_union_of_staged_and_unstaged(
271 self, code_repo: pathlib.Path
272 ) -> None:
273 """II3: flat deleted = staged.deleted ∪ unstaged.deleted."""
274 root = code_repo
275 (root / "to_delete.py").write_text("d = 1\n")
276 runner.invoke(cli, ["code", "add", "to_delete.py"], env=_env(root))
277 runner.invoke(cli, ["commit", "-m", "add to_delete"], env=_env(root))
278
279 # Delete and stage the deletion
280 (root / "to_delete.py").unlink()
281 runner.invoke(cli, ["code", "add", "to_delete.py"], env=_env(root))
282
283 data = _status_json(root)
284
285 flat_deleted = set(data["deleted"])
286 staged_deleted = set(data["staged"]["deleted"])
287 unstaged_deleted = set(data["unstaged"]["deleted"])
288 assert flat_deleted == staged_deleted | unstaged_deleted
289 assert "to_delete.py" in flat_deleted
290
291 def test_II4_total_changes_is_sum_of_flat(self, code_repo: pathlib.Path) -> None:
292 """II4: total_changes = len(added) + len(modified) + len(deleted) + len(renamed)."""
293 root = code_repo
294 (root / "main.py").write_text("x = 2\n")
295 (root / "new.py").write_text("n = 1\n")
296 runner.invoke(cli, ["code", "add", "main.py", "new.py"], env=_env(root))
297
298 data = _status_json(root)
299
300 expected = (
301 len(data["added"]) + len(data["modified"])
302 + len(data["deleted"]) + len(data["renamed"])
303 )
304 assert data["total_changes"] == expected
305
306 def test_II5_file_in_both_staged_and_unstaged_appears_once_flat(
307 self, code_repo: pathlib.Path
308 ) -> None:
309 """II5: A file staged then modified again appears once in flat modified."""
310 root = code_repo
311 (root / "main.py").write_text("x = 2\n")
312 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
313 (root / "main.py").write_text("x = 3\n") # modify again after staging
314
315 data = _status_json(root)
316
317 assert data["modified"].count("main.py") == 1, (
318 "main.py must appear exactly once in flat modified"
319 )
320
321
322 # ---------------------------------------------------------------------------
323 # III Stage-domain vs no-stage path
324 # ---------------------------------------------------------------------------
325
326
327 class TestStageDomainVsNonStageIII:
328 def test_III1_code_domain_staged_and_unstaged_are_dicts(
329 self, code_repo: pathlib.Path
330 ) -> None:
331 """III1: Code domain (has stage) — staged/unstaged are dicts, not null."""
332 root = code_repo
333 # Even clean — staging infrastructure exists, so never null
334 data = _status_json(root)
335
336 assert data["staged"] is not None, "staged must not be null for code domain"
337 assert data["unstaged"] is not None, "unstaged must not be null for code domain"
338 assert isinstance(data["staged"], dict)
339 assert isinstance(data["unstaged"], dict)
340
341 def test_III2_no_stage_index_staged_and_unstaged_are_null(
342 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
343 ) -> None:
344 """III2: When stage index is absent (domain has no staging), staged/unstaged are null."""
345 monkeypatch.chdir(tmp_path)
346 # Use mist domain — has no StagePlugin (unlike code domain)
347 result = runner.invoke(cli, ["init", "--domain", "mist"], env=_env(tmp_path))
348 assert result.exit_code == 0, result.output
349
350 data = _status_json(tmp_path)
351
352 assert data["staged"] is None, (
353 f"staged must be null for non-stage domain, got {data['staged']}"
354 )
355 assert data["unstaged"] is None, (
356 f"unstaged must be null for non-stage domain, got {data['unstaged']}"
357 )
358
359
360 # ---------------------------------------------------------------------------
361 # IV Specific field values
362 # ---------------------------------------------------------------------------
363
364
365 class TestSpecificFieldValuesIV:
366 def test_IV1_branch_matches_current_branch(self, code_repo: pathlib.Path) -> None:
367 """IV1: branch field matches the actual current branch."""
368 root = code_repo
369 data = _status_json(root)
370 assert data["branch"] == "main"
371
372 def test_IV2_head_commit_is_sha256_prefixed(self, code_repo: pathlib.Path) -> None:
373 """IV2: head_commit is sha256:-prefixed (not bare hex, not null after first commit)."""
374 root = code_repo
375 data = _status_json(root)
376 assert data["head_commit"] is not None
377 assert data["head_commit"].startswith("sha256:"), (
378 f"head_commit must be sha256:-prefixed, got {data['head_commit']!r}"
379 )
380
381 def test_IV3_clean_true_only_when_no_changes(self, code_repo: pathlib.Path) -> None:
382 """IV3: clean=True only when working tree matches HEAD exactly."""
383 root = code_repo
384 assert _status_json(root)["clean"] is True
385
386 (root / "main.py").write_text("x = 99\n")
387 assert _status_json(root)["clean"] is False
388
389 def test_IV4_dirty_is_not_clean(self, code_repo: pathlib.Path) -> None:
390 """IV4: dirty = not clean, always — both are always present."""
391 root = code_repo
392
393 data_clean = _status_json(root)
394 assert data_clean["dirty"] is not data_clean["clean"]
395
396 (root / "main.py").write_text("x = 99\n")
397 data_dirty = _status_json(root)
398 assert data_dirty["dirty"] is not data_dirty["clean"]
399 assert data_dirty["dirty"] is True
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago