gabriel / muse public
test_diff_json_schema.py python
361 lines 15.8 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 diff --json`` schema.
2
3 Muse is a symbol-aware VCS. Its diff engine works at the symbol level, not just
4 the file level. The JSON output must expose that — otherwise agents lose the very
5 information that makes Muse different from a file-hashing VCS.
6
7 Canonical schema
8 ----------------
9 ::
10
11 {
12 "from_ref": str, // "HEAD", branch, or commit id
13 "to_ref": str, // "working tree", "staged", or commit id
14 "from_commit_id": str | null, // sha256:-prefixed or null
15 "to_commit_id": str | null, // sha256:-prefixed or null
16 "has_changes": bool,
17 "added": [str, ...], // file paths added
18 "deleted": [str, ...], // file paths deleted
19 "modified": [str, ...], // file paths modified in-place
20 "renamed": {str: str}, // {old_path: new_path}
21 "total_changes": int, // len(added)+len(modified)+len(deleted)+len(renamed)
22 "symbols": { // per-file symbol-level changes
23 "<file_path>": {
24 "added": [str, ...], // symbol names inserted
25 "deleted": [str, ...], // symbol names deleted
26 "modified": [str, ...] // symbol names replaced / patched
27 }
28 },
29 "sem_ver_bump": str, // "none" | "patch" | "minor" | "major"
30 "breaking_changes": [str, ...] // addresses of breaking symbol changes
31 }
32
33 Coverage matrix
34 ---------------
35 I Schema invariants
36 I1 All required keys present on clean repo (no changes)
37 I2 All required keys present when changes exist
38 I3 from_commit_id is sha256:-prefixed
39 I4 has_changes=false when clean, true when dirty
40
41 II File-level categorisation
42 II1 Added file appears in added, not modified or deleted
43 II2 Deleted file appears in deleted, not modified or added
44 II3 Modified file appears in modified
45 II4 total_changes = len(added) + len(modified) + len(deleted) + len(renamed)
46 II5 Renamed file appears in renamed dict, NOT in modified or added/deleted
47
48 III Symbol-level output (the Muse differentiator)
49 III1 symbols dict present even when empty (clean diff → {})
50 III2 New function in a modified file appears in symbols[file].added
51 III3 Deleted function in a modified file appears in symbols[file].deleted
52 III4 File-only add (no symbols) does not appear in symbols (or appears with empty buckets)
53
54 IV Semantic fields
55 IV1 sem_ver_bump always present (at least "none")
56 IV2 breaking_changes always present (at least [])
57 IV3 sem_ver_bump reflects the bump level of the changes
58
59 V Diff modes
60 V1 --staged shows staged vs HEAD (to_ref == "staged")
61 V2 --staged no_changes=false when staged changes exist
62 V3 Default (no flag) shows working tree vs HEAD (to_ref == "working tree")
63 V4 Commit-to-commit diff uses sha256:-prefixed to_commit_id
64 """
65
66 from __future__ import annotations
67
68 import json
69 import pathlib
70
71 import pytest
72
73 from tests.cli_test_helper import CliRunner
74
75 cli = None
76 runner = CliRunner()
77
78 _REQUIRED_KEYS = {
79 "from_ref", "to_ref", "from_commit_id", "to_commit_id",
80 "has_changes",
81 "added", "deleted", "modified", "renamed",
82 "total_changes",
83 "symbols",
84 "sem_ver_bump", "breaking_changes",
85 }
86
87 _SYMBOL_BUCKET_KEYS = {"added", "deleted", "modified"}
88
89
90 def _env(root: pathlib.Path) -> dict[str, str]:
91 return {"MUSE_REPO_ROOT": str(root)}
92
93
94 def _diff_json(root: pathlib.Path, *extra_args: str) -> dict:
95 result = runner.invoke(cli, ["diff", "--json"] + list(extra_args), env=_env(root))
96 assert result.exit_code == 0, f"diff --json failed: {result.output}"
97 return json.loads(result.output.strip())
98
99
100 @pytest.fixture()
101 def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
102 """Code-domain repo with one committed Python file."""
103 monkeypatch.chdir(tmp_path)
104 result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
105 assert result.exit_code == 0, result.output
106 (tmp_path / "module.py").write_text("def greet():\n return 'hello'\n")
107 runner.invoke(cli, ["code", "add", "module.py"], env=_env(tmp_path))
108 result = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path))
109 assert result.exit_code == 0, result.output
110 return tmp_path
111
112
113 # ---------------------------------------------------------------------------
114 # I Schema invariants
115 # ---------------------------------------------------------------------------
116
117
118 class TestSchemaInvariantsI:
119 def test_I1_clean_repo_all_keys_present(self, code_repo: pathlib.Path) -> None:
120 """I1: All required keys present even when there are no changes."""
121 data = _diff_json(code_repo)
122 missing = _REQUIRED_KEYS - data.keys()
123 assert not missing, f"Missing keys on clean diff: {missing}"
124
125 def test_I2_dirty_repo_all_keys_present(self, code_repo: pathlib.Path) -> None:
126 """I2: All required keys present when changes exist."""
127 (code_repo / "module.py").write_text(
128 "def greet():\n return 'hello'\n\ndef farewell():\n return 'bye'\n"
129 )
130 data = _diff_json(code_repo)
131 missing = _REQUIRED_KEYS - data.keys()
132 assert not missing, f"Missing keys on dirty diff: {missing}"
133
134 def test_I3_from_commit_id_is_sha256_prefixed(self, code_repo: pathlib.Path) -> None:
135 """I3: from_commit_id is sha256:-prefixed."""
136 data = _diff_json(code_repo)
137 assert data["from_commit_id"] is not None
138 assert data["from_commit_id"].startswith("sha256:"), (
139 f"from_commit_id must be sha256:-prefixed, got {data['from_commit_id']!r}"
140 )
141
142 def test_I4_has_changes_reflects_dirty_state(self, code_repo: pathlib.Path) -> None:
143 """I4: has_changes=false when nothing staged, true when staged changes exist.
144
145 Uses --staged rather than the working-tree diff because muse init
146 creates .museattributes/.museignore in the working tree without
147 committing them, so the working-tree diff is never truly clean after
148 init. The staged view is clean after a commit with nothing staged.
149 """
150 assert _diff_json(code_repo, "--staged")["has_changes"] is False
151 (code_repo / "module.py").write_text("def greet():\n return 'hi'\n")
152 runner.invoke(cli, ["code", "add", "module.py"], env=_env(code_repo))
153 assert _diff_json(code_repo, "--staged")["has_changes"] is True
154
155
156 # ---------------------------------------------------------------------------
157 # II File-level categorisation
158 # ---------------------------------------------------------------------------
159
160
161 class TestFileLevelCategorizationII:
162 def test_II1_added_file_in_added(self, code_repo: pathlib.Path) -> None:
163 """II1: A newly staged file appears in added, not modified or deleted."""
164 (code_repo / "new.py").write_text("x = 1\n")
165 runner.invoke(cli, ["code", "add", "new.py"], env=_env(code_repo))
166
167 data = _diff_json(code_repo, "--staged")
168 assert "new.py" in data["added"], f"new.py not in added: {data}"
169 assert "new.py" not in data["modified"]
170 assert "new.py" not in data["deleted"]
171
172 def test_II2_deleted_file_in_deleted(self, code_repo: pathlib.Path) -> None:
173 """II2: A staged deletion appears in deleted, not modified or added."""
174 (code_repo / "module.py").unlink()
175 runner.invoke(cli, ["code", "add", "module.py"], env=_env(code_repo))
176
177 data = _diff_json(code_repo, "--staged")
178 assert "module.py" in data["deleted"], f"module.py not in deleted: {data}"
179 assert "module.py" not in data["modified"]
180 assert "module.py" not in data["added"]
181
182 def test_II3_modified_file_in_modified(self, code_repo: pathlib.Path) -> None:
183 """II3: An in-place edit appears in modified."""
184 (code_repo / "module.py").write_text("def greet():\n return 'hi'\n")
185 data = _diff_json(code_repo)
186 assert "module.py" in data["modified"], f"module.py not in modified: {data}"
187
188 def test_II4_total_changes_formula(self, code_repo: pathlib.Path) -> None:
189 """II4: total_changes = len(added) + len(modified) + len(deleted) + len(renamed)."""
190 (code_repo / "module.py").write_text("def greet():\n return 'hi'\n")
191 (code_repo / "extra.py").write_text("y = 2\n")
192 runner.invoke(cli, ["code", "add", "extra.py"], env=_env(code_repo))
193
194 data = _diff_json(code_repo)
195 expected = (
196 len(data["added"]) + len(data["modified"])
197 + len(data["deleted"]) + len(data["renamed"])
198 )
199 assert data["total_changes"] == expected, (
200 f"total_changes {data['total_changes']} != formula {expected}"
201 )
202
203 def test_II5_renamed_file_in_renamed_not_modified(self, code_repo: pathlib.Path) -> None:
204 """II5: A renamed file appears in renamed dict, not in modified or added/deleted."""
205 runner.invoke(
206 cli, ["mv", "module.py", "utils.py"], env=_env(code_repo)
207 )
208
209 data = _diff_json(code_repo, "--staged")
210 assert "module.py" in data["renamed"], (
211 f"module.py not a rename source. renamed={data['renamed']}, "
212 f"modified={data['modified']}, added={data['added']}, deleted={data['deleted']}"
213 )
214 assert data["renamed"]["module.py"] == "utils.py", (
215 f"Expected renamed['module.py']='utils.py', got {data['renamed']}"
216 )
217 assert "utils.py" not in data["added"], "rename target must not appear in added"
218 assert "module.py" not in data["deleted"], "rename source must not appear in deleted"
219 assert "module.py" not in data["modified"], "rename source must not appear in modified"
220
221
222 # ---------------------------------------------------------------------------
223 # III Symbol-level output
224 # ---------------------------------------------------------------------------
225
226
227 class TestSymbolLevelOutputIII:
228 def test_III1_symbols_always_present(self, code_repo: pathlib.Path) -> None:
229 """III1: symbols dict is always present, even on a clean diff."""
230 data = _diff_json(code_repo)
231 assert "symbols" in data
232 assert isinstance(data["symbols"], dict)
233 assert data["symbols"] == {}
234
235 def test_III2_new_function_in_symbols_added(self, code_repo: pathlib.Path) -> None:
236 """III2: Adding a new function appears in symbols[file].added."""
237 (code_repo / "module.py").write_text(
238 "def greet():\n return 'hello'\n\ndef farewell():\n return 'bye'\n"
239 )
240 data = _diff_json(code_repo)
241
242 assert "module.py" in data["symbols"], (
243 f"module.py not in symbols: {data['symbols']}"
244 )
245 sym = data["symbols"]["module.py"]
246 assert _SYMBOL_BUCKET_KEYS == set(sym.keys()), (
247 f"Symbol bucket has wrong keys: {sym.keys()}"
248 )
249 assert "farewell" in sym["added"], (
250 f"Expected 'farewell' in symbols.module.py.added, got {sym['added']}"
251 )
252
253 def test_III3_deleted_function_in_symbols_deleted(self, code_repo: pathlib.Path) -> None:
254 """III3: Removing a function appears in symbols[file].deleted."""
255 # First add a second function
256 (code_repo / "module.py").write_text(
257 "def greet():\n return 'hello'\n\ndef farewell():\n return 'bye'\n"
258 )
259 runner.invoke(cli, ["code", "add", "module.py"], env=_env(code_repo))
260 runner.invoke(cli, ["commit", "-m", "add farewell"], env=_env(code_repo))
261
262 # Now delete it
263 (code_repo / "module.py").write_text("def greet():\n return 'hello'\n")
264 data = _diff_json(code_repo)
265
266 assert "module.py" in data["symbols"]
267 sym = data["symbols"]["module.py"]
268 assert "farewell" in sym["deleted"], (
269 f"Expected 'farewell' in symbols.module.py.deleted, got {sym['deleted']}"
270 )
271
272 def test_III4_added_file_symbols_in_symbols_or_omitted(
273 self, code_repo: pathlib.Path
274 ) -> None:
275 """III4: Newly added file's symbols appear in symbols[file].added or file omitted."""
276 (code_repo / "fresh.py").write_text("def new_func():\n pass\n")
277 runner.invoke(cli, ["code", "add", "fresh.py"], env=_env(code_repo))
278
279 data = _diff_json(code_repo, "--staged")
280 assert "fresh.py" in data["added"]
281 # If symbols present for the new file, all symbols should be in added
282 if "fresh.py" in data["symbols"]:
283 assert "new_func" in data["symbols"]["fresh.py"]["added"], (
284 f"Expected new_func in symbols for new file: {data['symbols']['fresh.py']}"
285 )
286
287
288 # ---------------------------------------------------------------------------
289 # IV Semantic fields
290 # ---------------------------------------------------------------------------
291
292
293 class TestSemanticFieldsIV:
294 def test_IV1_sem_ver_bump_always_present(self, code_repo: pathlib.Path) -> None:
295 """IV1: sem_ver_bump always present, at least 'none'."""
296 data = _diff_json(code_repo)
297 assert "sem_ver_bump" in data
298 assert isinstance(data["sem_ver_bump"], str)
299 assert data["sem_ver_bump"] == "none" # clean repo
300
301 def test_IV2_breaking_changes_always_present(self, code_repo: pathlib.Path) -> None:
302 """IV2: breaking_changes always present, at least []."""
303 data = _diff_json(code_repo)
304 assert "breaking_changes" in data
305 assert isinstance(data["breaking_changes"], list)
306
307 def test_IV3_sem_ver_bump_reflects_changes(self, code_repo: pathlib.Path) -> None:
308 """IV3: sem_ver_bump is 'none' when clean, non-'none' when changes exist."""
309 # Clean → "none"
310 assert _diff_json(code_repo)["sem_ver_bump"] == "none"
311
312 # Any change should produce a non-"none" bump
313 (code_repo / "module.py").write_text(
314 "def greet():\n return 'hello'\n\ndef farewell():\n return 'bye'\n"
315 )
316 data = _diff_json(code_repo)
317 assert data["sem_ver_bump"] != "none", (
318 f"Expected non-none sem_ver_bump for dirty diff, got {data['sem_ver_bump']!r}"
319 )
320
321
322 # ---------------------------------------------------------------------------
323 # V Diff modes
324 # ---------------------------------------------------------------------------
325
326
327 class TestDiffModesV:
328 def test_V1_staged_flag_sets_to_ref(self, code_repo: pathlib.Path) -> None:
329 """V1: --staged sets to_ref to 'staged'."""
330 data = _diff_json(code_repo, "--staged")
331 assert data["to_ref"] == "staged", (
332 f"Expected to_ref='staged', got {data['to_ref']!r}"
333 )
334
335 def test_V2_staged_flag_shows_staged_changes(self, code_repo: pathlib.Path) -> None:
336 """V2: --staged shows staged changes as has_changes=true."""
337 (code_repo / "module.py").write_text("def greet():\n return 'hi'\n")
338 runner.invoke(cli, ["code", "add", "module.py"], env=_env(code_repo))
339
340 assert _diff_json(code_repo, "--staged")["has_changes"] is True
341
342 def test_V3_default_shows_working_tree(self, code_repo: pathlib.Path) -> None:
343 """V3: Default diff (no flags) uses to_ref='working tree'."""
344 data = _diff_json(code_repo)
345 assert data["to_ref"] == "working tree", (
346 f"Expected to_ref='working tree', got {data['to_ref']!r}"
347 )
348
349 def test_V4_commit_to_commit_diff_has_sha256_to_commit_id(
350 self, code_repo: pathlib.Path
351 ) -> None:
352 """V4: Commit-to-commit diff populates to_commit_id with sha256:-prefixed ID."""
353 import json as _json
354 log_out = runner.invoke(cli, ["log", "--json", "-n", "1"], env=_env(code_repo))
355 head_id = _json.loads(log_out.output)["commits"][0]["commit_id"]
356
357 data = _diff_json(code_repo, head_id, head_id)
358 assert data["to_commit_id"] is not None
359 assert data["to_commit_id"].startswith("sha256:"), (
360 f"to_commit_id must be sha256:-prefixed, got {data['to_commit_id']!r}"
361 )
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