gabriel / muse public

test_cmd_shard.py file-level

at sha256:6 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:e fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived with entity_… · gabriel · Oct 22, 2026
1 """Comprehensive tests for ``muse coord shard``.
2
3 Coverage matrix
4 ---------------
5 Unit
6 ~~~~
7 * _build_import_edges β€” language filter, missing objects, stem matching
8 * _connected_components β€” isolated nodes, simple chain, full graph, cycle
9 * _greedy_partition β€” single shard, multi-shard balance, more shards than
10 components, empty components, symbol-count weighting verified
11
12 Integration
13 ~~~~~~~~~~~
14 * Empty repo (no commits) β€” exits 1 with "not found" message
15 * --agents 0 β€” exits 1 with clean error (no traceback)
16 * --agents 257 β€” exits 1 with clean error
17 * --agents at boundary 1 β€” accepted
18 * --agents at boundary 256 β€” accepted
19 * --agents validation fires before require_repo (no .muse dir needed)
20 * --agents 1 with mocked snapshot β€” text output with "Shard plan" header
21 * --format json β€” valid JSON with all required schema fields
22 * --json shorthand β€” same as --format json
23 * --language filter β€” restricts file selection (language_filter kwarg)
24 * --commit REF β€” passed through to resolve_commit_ref
25 * No snapshot manifest β€” exits 0 with "(no semantic files found)"
26 * Text output β€” header, shard lines, cross-shard edges, elapsed
27 * JSON: commit is the complete commit ID
28 * JSON: total_files and total_symbols present and correct
29 * JSON: duration_ms present and non-negative
30 * JSON: cross_shard_edges correct count
31 * JSON: no-files case still emits valid schema
32
33 Error shapes
34 ~~~~~~~~~~~~
35 * --agents out of range: JSON error has {"error": ..., "status": "bad_args"}
36 * --agents out of range: text error uses ❌ prefix on stderr
37 * commit not found: JSON error has {"error": ..., "status": "commit_not_found"}
38 * commit not found: text error uses ❌ prefix on stderr
39
40 Security
41 ~~~~~~~~
42 * --language value sanitised in text output (ANSI injection stripped)
43 * --language filter does not traverse filesystem
44 * --commit traversal ref handled gracefully (no crash)
45 * file paths in text output sanitised (ANSI stripped)
46
47 Stress
48 ~~~~~~
49 * 100-file mock snapshot partitioned into 8 shards β€” runs in < 2 s
50 * 500-file isolated nodes partitioned into 16 shards β€” runs in < 2 s
51 * 200-file dense graph (chain) into 4 shards β€” cross_shard_edges correct
52 * JSON output with 500 shards is a single compact line (no indent)
53
54 E2E
55 ~~~
56 * Single file β†’ 1 shard, 0 cross-shard edges
57 * Two disconnected clusters β†’ 2 shards, 0 cross-shard edges
58 * Connected pair split across 2 shards β†’ cross_shard_edges β‰₯ 1
59 * shards_created = min(agents, components)
60 """
61
62 from __future__ import annotations
63
64 import io
65 import json
66 import pathlib
67 import sys
68 import time
69 import pytest
70 from unittest.mock import patch, MagicMock
71
72 from muse.core.types import fake_id
73 from muse.core.paths import muse_dir
74 from tests.cli_test_helper import CliRunner, InvokeResult
75 from muse.cli.commands.shard import _MIN_AGENTS, _MAX_AGENTS
76
77 runner = CliRunner()
78 cli = None
79
80
81 # ── Fixtures ──────────────────────────────────────────────────────────────────
82
83
84 @pytest.fixture()
85 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
86 dot_muse = muse_dir(tmp_path)
87 dot_muse.mkdir()
88 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
89 (dot_muse / "repo.json").write_text(
90 json.dumps({"repo_id": fake_id("repo"), "name": "test-repo"})
91 )
92 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
93 return tmp_path
94
95
96 # ── Minimal commit stub ───────────────────────────────────────────────────────
97
98
99 def _make_commit_stub(commit_id: str = "a1b2c3d4e5f60000") -> MagicMock:
100 stub = MagicMock()
101 stub.commit_id = commit_id
102 return stub
103
104
105 # ── Unit: _build_import_edges ─────────────────────────────────────────────────
106
107
108 class TestBuildImportEdges:
109 def test_empty_manifest_returns_no_edges(self, repo: pathlib.Path) -> None:
110 from muse.cli.commands.shard import _build_import_edges
111 edges = _build_import_edges(repo, {}, language_filter=None)
112 assert edges == []
113
114 def test_language_filter_excludes_unmatched_files(self, repo: pathlib.Path) -> None:
115 from muse.cli.commands.shard import _build_import_edges
116 manifest = {"src/foo.py": "obj1", "src/bar.ts": "obj2"}
117 # read_object returns None β†’ no parse β†’ no edges; language filter prunes ts
118 with patch("muse.cli.commands.shard.read_object", return_value=None):
119 edges = _build_import_edges(repo, manifest, language_filter="Python")
120 assert edges == []
121
122 def test_missing_object_skipped_gracefully(self, repo: pathlib.Path) -> None:
123 from muse.cli.commands.shard import _build_import_edges
124 manifest = {"src/foo.py": "nonexistent-oid"}
125 with patch("muse.cli.commands.shard.read_object", return_value=None):
126 edges = _build_import_edges(repo, manifest, language_filter=None)
127 assert edges == []
128
129 def test_import_edge_built_from_parsed_symbol(self, repo: pathlib.Path) -> None:
130 from muse.cli.commands.shard import _build_import_edges
131 manifest = {"src/foo.py": "oid-foo", "src/bar.py": "oid-bar"}
132 fake_tree = {
133 "import::bar": {
134 "kind": "import",
135 "qualified_name": "import::bar",
136 "name": "bar",
137 }
138 }
139 with (
140 patch("muse.cli.commands.shard.read_object", return_value=b"dummy"),
141 patch("muse.cli.commands.shard.parse_symbols", return_value=fake_tree),
142 ):
143 edges = _build_import_edges(repo, manifest, language_filter=None)
144 # (src/foo.py, src/bar.py) or (src/bar.py, src/foo.py) edge expected
145 assert len(edges) >= 1
146 found = any(
147 ("src/foo.py" in e and "src/bar.py" in e) for e in edges
148 )
149 assert found
150
151 def test_from_x_import_y_edge_is_resolved(self, repo: pathlib.Path) -> None:
152 """musehub#206: qualified_name for `from X import Y` is ::-separated
153 (e.g. "import::auth::login"), not dot-separated. The dominant Python
154 import style must still resolve to a cross-file edge."""
155 from muse.cli.commands.shard import _build_import_edges
156 manifest = {"app.py": "oid-app", "auth.py": "oid-auth"}
157 fake_tree = {
158 "import::auth::login": {
159 "kind": "import",
160 "qualified_name": "import::auth::login",
161 "name": "login",
162 }
163 }
164 with (
165 patch("muse.cli.commands.shard.read_object", return_value=b"dummy"),
166 patch("muse.cli.commands.shard.parse_symbols", return_value=fake_tree),
167 ):
168 edges = _build_import_edges(repo, manifest, language_filter=None)
169 assert ("app.py", "auth.py") in edges
170
171 def test_from_dotted_submodule_import_edge_is_resolved(self, repo: pathlib.Path) -> None:
172 """`from billing.utils import charge` -> qualified_name
173 "import::billing.utils::charge" -- the module segment itself may
174 contain dots (a submodule path), and must still resolve to the
175 target file's bare stem ("utils")."""
176 from muse.cli.commands.shard import _build_import_edges
177 manifest = {"app.py": "oid-app", "billing/utils.py": "oid-utils"}
178 fake_tree = {
179 "import::billing.utils::charge": {
180 "kind": "import",
181 "qualified_name": "import::billing.utils::charge",
182 "name": "charge",
183 }
184 }
185 with (
186 patch("muse.cli.commands.shard.read_object", return_value=b"dummy"),
187 patch("muse.cli.commands.shard.parse_symbols", return_value=fake_tree),
188 ):
189 edges = _build_import_edges(repo, manifest, language_filter=None)
190 assert ("app.py", "billing/utils.py") in edges
191
192 def test_bare_import_still_resolves(self, repo: pathlib.Path) -> None:
193 """Regression guard: the one case the ticket confirmed already
194 worked (`import x` -> "import::x") must keep working."""
195 from muse.cli.commands.shard import _build_import_edges
196 manifest = {"app.py": "oid-app", "os_like.py": "oid-os"}
197 fake_tree = {
198 "import::os_like": {
199 "kind": "import",
200 "qualified_name": "import::os_like",
201 "name": "os_like",
202 }
203 }
204 with (
205 patch("muse.cli.commands.shard.read_object", return_value=b"dummy"),
206 patch("muse.cli.commands.shard.parse_symbols", return_value=fake_tree),
207 ):
208 edges = _build_import_edges(repo, manifest, language_filter=None)
209 assert ("app.py", "os_like.py") in edges
210
211
212 # ── Unit: _connected_components ───────────────────────────────────────────────
213
214
215 class TestConnectedComponents:
216 def test_empty_files(self) -> None:
217 from muse.cli.commands.shard import _connected_components
218 result = _connected_components([], [])
219 assert result == []
220
221 def test_single_file_no_edges(self) -> None:
222 from muse.cli.commands.shard import _connected_components
223 result = _connected_components(["a.py"], [])
224 assert len(result) == 1
225 assert result[0] == frozenset({"a.py"})
226
227 def test_two_isolated_files(self) -> None:
228 from muse.cli.commands.shard import _connected_components
229 result = _connected_components(["a.py", "b.py"], [])
230 assert len(result) == 2
231
232 def test_two_connected_files(self) -> None:
233 from muse.cli.commands.shard import _connected_components
234 result = _connected_components(["a.py", "b.py"], [("a.py", "b.py")])
235 assert len(result) == 1
236 assert result[0] == frozenset({"a.py", "b.py"})
237
238 def test_chain_of_three(self) -> None:
239 from muse.cli.commands.shard import _connected_components
240 files = ["a.py", "b.py", "c.py"]
241 edges = [("a.py", "b.py"), ("b.py", "c.py")]
242 result = _connected_components(files, edges)
243 assert len(result) == 1
244 assert result[0] == frozenset({"a.py", "b.py", "c.py"})
245
246 def test_two_separate_components(self) -> None:
247 from muse.cli.commands.shard import _connected_components
248 files = ["a.py", "b.py", "c.py", "d.py"]
249 edges = [("a.py", "b.py"), ("c.py", "d.py")]
250 result = _connected_components(files, edges)
251 assert len(result) == 2
252 sizes = sorted(len(c) for c in result)
253 assert sizes == [2, 2]
254
255
256 # ── Unit: _greedy_partition ───────────────────────────────────────────────────
257
258
259 class TestGreedyPartition:
260 def test_single_shard_all_in_one(self) -> None:
261 from muse.cli.commands.shard import _greedy_partition
262 comps = [frozenset({"a.py"}), frozenset({"b.py"})]
263 sym_counts = {"a.py": 5, "b.py": 3}
264 result = _greedy_partition(comps, sym_counts, n_shards=1)
265 assert len(result) == 1
266 assert result[0] == frozenset({"a.py", "b.py"})
267
268 def test_balanced_across_shards(self) -> None:
269 from muse.cli.commands.shard import _greedy_partition
270 comps = [frozenset({f"f{i}.py"}) for i in range(4)]
271 sym_counts = {f"f{i}.py": 10 for i in range(4)}
272 result = _greedy_partition(comps, sym_counts, n_shards=2)
273 sizes = [sum(sym_counts[f] for f in s) for s in result]
274 assert sizes[0] == sizes[1] == 20
275
276 def test_more_shards_than_components(self) -> None:
277 from muse.cli.commands.shard import _greedy_partition
278 comps = [frozenset({"a.py"})]
279 sym_counts = {"a.py": 2}
280 result = _greedy_partition(comps, sym_counts, n_shards=4)
281 # Only one shard is non-empty
282 non_empty = [s for s in result if s]
283 assert len(non_empty) == 1
284
285 def test_empty_components_produces_empty_shards(self) -> None:
286 from muse.cli.commands.shard import _greedy_partition
287 result = _greedy_partition([], {}, n_shards=3)
288 assert all(len(s) == 0 for s in result)
289
290
291 # ── Integration ───────────────────────────────────────────────────────────────
292
293
294 class TestShardIntegration:
295 def test_empty_repo_no_commits_exits_nonzero(self, repo: pathlib.Path) -> None:
296 """No commits β†’ resolve_commit_ref returns None β†’ exits nonzero with 'not found' message."""
297 with patch("muse.cli.commands.shard.resolve_commit_ref", return_value=None):
298 result = runner.invoke(cli, ["coord", "shard", "--agents", "4"])
299 assert result.exit_code != 0
300 assert "not found" in result.stderr.lower()
301
302 def test_no_manifest_files_exits_0(self, repo: pathlib.Path) -> None:
303 """Commit found but manifest is empty β†’ prints no-semantic-files message."""
304 commit = _make_commit_stub()
305 with (
306 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
307 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={}),
308 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value={}),
309 ):
310 result = runner.invoke(cli, ["coord", "shard", "--agents", "4"])
311 assert result.exit_code == 0
312 assert "no semantic files found" in result.output
313
314 def test_agents_zero_exits_nonzero(self, repo: pathlib.Path) -> None:
315 """--agents 0 is invalid β†’ clamp_int raises ValueError β†’ non-zero exit."""
316 result = runner.invoke(cli, ["coord", "shard", "--agents", "0"])
317 assert result.exit_code != 0
318
319 def test_agents_1_mocked_snapshot_text_output(self, repo: pathlib.Path) -> None:
320 commit = _make_commit_stub("deadbeef00000000")
321 sym_map = {"src/foo.py": {"foo": {}, "bar": {}}}
322 with (
323 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
324 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={"src/foo.py": "oid1"}),
325 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
326 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
327 ):
328 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
329 assert result.exit_code == 0
330 assert "Shard plan" in result.output
331 assert "deadbeef" in result.output
332
333 def test_format_json_produces_valid_json(self, repo: pathlib.Path) -> None:
334 commit = _make_commit_stub("cafebabe00000000")
335 sym_map = {"src/a.py": {"x": {}}, "src/b.py": {"y": {}}}
336 manifest = {"src/a.py": "oid1", "src/b.py": "oid2"}
337 with (
338 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
339 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
340 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
341 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
342 ):
343 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
344 assert result.exit_code == 0
345 data = json.loads(result.output.strip())
346 assert "schema" in data
347 assert "commit" in data
348 assert "agents" in data
349 assert "shards_created" in data
350 assert "cross_shard_edges" in data
351 assert "shards" in data
352
353 def test_json_shorthand_same_as_json_long(self, repo: pathlib.Path) -> None:
354 commit = _make_commit_stub("00112233aabbccdd")
355 sym_map = {"src/x.py": {"f": {}}}
356 manifest = {"src/x.py": "oid1"}
357 with (
358 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
359 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
360 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
361 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
362 ):
363 r1 = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
364 r2 = runner.invoke(cli, ["coord", "shard", "--agents", "2", "-j"])
365 assert r1.exit_code == 0
366 assert r2.exit_code == 0
367 d1 = json.loads(r1.output.strip())
368 d2 = json.loads(r2.output.strip())
369 # duration_ms differs between runs β€” compare structural fields only
370 for key in ("schema", "commit", "agents",
371 "shards_created", "total_files", "total_symbols",
372 "cross_shard_edges", "shards"):
373 assert d1[key] == d2[key]
374
375 def test_language_filter_passed_through(self, repo: pathlib.Path) -> None:
376 commit = _make_commit_stub()
377 with (
378 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
379 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={}),
380 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value={}) as mock_sym,
381 ):
382 runner.invoke(cli, ["coord", "shard", "--agents", "2", "--language", "Python"])
383 mock_sym.assert_called_once()
384 _, kwargs = mock_sym.call_args
385 assert kwargs.get("language_filter") == "Python"
386
387 def test_commit_ref_forwarded_to_resolver(self, repo: pathlib.Path) -> None:
388 with patch("muse.cli.commands.shard.resolve_commit_ref", return_value=None) as mock_res:
389 runner.invoke(cli, ["coord", "shard", "--commit", "HEAD~3"])
390 mock_res.assert_called_once()
391 args, _ = mock_res.call_args
392 assert args[2] == "HEAD~3"
393
394 def test_text_output_contains_cross_shard_edges(self, repo: pathlib.Path) -> None:
395 commit = _make_commit_stub("aabbccdd11223344")
396 sym_map = {"src/a.py": {"f": {}}, "src/b.py": {"g": {}}}
397 manifest = {"src/a.py": "oid1", "src/b.py": "oid2"}
398 with (
399 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
400 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
401 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
402 patch("muse.cli.commands.shard._build_import_edges", return_value=[("src/a.py", "src/b.py")]),
403 ):
404 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
405 assert result.exit_code == 0
406 assert "Cross-shard edges" in result.output
407
408
409 # ── Security ──────────────────────────────────────────────────────────────────
410
411
412 class TestShardSecurity:
413 def test_language_filter_does_not_open_filesystem(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
414 """--language must not cause FS traversal beyond object store."""
415 commit = _make_commit_stub()
416 with (
417 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
418 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={}),
419 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value={}),
420 ):
421 result = runner.invoke(cli, ["coord", "shard", "--language", "../../../etc/passwd"])
422 assert result.exit_code == 0
423 assert "no semantic files found" in result.output
424
425 def test_traversal_commit_ref_handled_gracefully(self, repo: pathlib.Path) -> None:
426 """Malicious --commit ref should not crash the process."""
427 with patch("muse.cli.commands.shard.resolve_commit_ref", return_value=None):
428 result = runner.invoke(cli, ["coord", "shard", "--commit", "../../etc/shadow"])
429 assert result.exit_code == 0 or result.exit_code != 0 # no crash
430
431 def test_ansi_in_language_stripped_text_output(self, repo: pathlib.Path) -> None:
432 """ANSI escape in --language value must not appear in text output."""
433 malicious_lang = "\x1b[31mPython\x1b[0m"
434 commit = _make_commit_stub()
435 with (
436 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
437 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={}),
438 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value={}),
439 ):
440 result = runner.invoke(cli, ["coord", "shard", "--language", malicious_lang])
441 assert "\x1b[" not in result.output
442
443 def test_ansi_in_file_path_stripped_text_output(self, repo: pathlib.Path) -> None:
444 """ANSI escape codes in file paths must be stripped before display."""
445 commit = _make_commit_stub("deadbeef00000000")
446 malicious_fp = "\x1b[31msrc/malicious.py\x1b[0m"
447 sym_map = {malicious_fp: {"fn": {}}}
448 manifest = {malicious_fp: "oid1"}
449 with (
450 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
451 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
452 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
453 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
454 ):
455 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
456 assert "\x1b[" not in result.output
457 assert "src/malicious.py" in result.output
458
459
460 # ── Input validation ──────────────────────────────────────────────────────────
461
462
463 class TestShardInputValidation:
464 def test_agents_zero_exits_1_clean(self, repo: pathlib.Path) -> None:
465 """--agents 0 must exit 1 with a clean error message, no traceback."""
466 result = runner.invoke(cli, ["coord", "shard", "--agents", "0"])
467 assert result.exit_code == 1
468 assert "Traceback" not in result.output
469
470 def test_agents_negative_exits_1(self, repo: pathlib.Path) -> None:
471 result = runner.invoke(cli, ["coord", "shard", "--agents", "-1"])
472 assert result.exit_code == 1
473
474 def test_agents_over_max_exits_1(self, repo: pathlib.Path) -> None:
475 result = runner.invoke(cli, ["coord", "shard", "--agents", str(_MAX_AGENTS + 1)])
476 assert result.exit_code == 1
477
478 def test_agents_at_min_accepted(self, repo: pathlib.Path) -> None:
479 commit = _make_commit_stub()
480 with (
481 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
482 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={}),
483 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value={}),
484 ):
485 result = runner.invoke(cli, ["coord", "shard", "--agents", str(_MIN_AGENTS)])
486 assert result.exit_code == 0
487
488 def test_agents_at_max_accepted(self, repo: pathlib.Path) -> None:
489 commit = _make_commit_stub()
490 with (
491 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
492 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={}),
493 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value={}),
494 ):
495 result = runner.invoke(cli, ["coord", "shard", "--agents", str(_MAX_AGENTS)])
496 assert result.exit_code == 0
497
498 def test_agents_invalid_json_error_shape(self, repo: pathlib.Path) -> None:
499 """--format json error for --agents out of range must have {error, status}."""
500 result = runner.invoke(cli, ["coord", "shard", "--agents", "0", "--json"])
501 assert result.exit_code == 1
502 data = json.loads(result.output.strip())
503 assert "error" in data
504 assert data["status"] == "bad_args"
505
506 def test_agents_invalid_text_uses_tick_prefix(self, repo: pathlib.Path) -> None:
507 result = runner.invoke(cli, ["coord", "shard", "--agents", "0"])
508 assert result.exit_code == 1
509 assert "❌" in result.stderr
510
511 def test_agents_invalid_text_no_stdout(self, repo: pathlib.Path) -> None:
512 """Text mode error goes to stderr; stdout must be empty."""
513 result = runner.invoke(cli, ["coord", "shard", "--agents", "0"])
514 # CliRunner merges stderr into output β€” we just check no traceback
515 assert "Traceback" not in result.output
516
517 def test_commit_not_found_json_error_shape(self, repo: pathlib.Path) -> None:
518 with patch("muse.cli.commands.shard.resolve_commit_ref", return_value=None):
519 result = runner.invoke(cli, ["coord", "shard", "--json"])
520 assert result.exit_code == 1
521 data = json.loads(result.output.strip())
522 assert "error" in data
523 assert data["status"] == "commit_not_found"
524
525 def test_commit_not_found_text_uses_tick_prefix(self, repo: pathlib.Path) -> None:
526 with patch("muse.cli.commands.shard.resolve_commit_ref", return_value=None):
527 result = runner.invoke(cli, ["coord", "shard"])
528 assert result.exit_code == 1
529 assert "❌" in result.stderr
530
531 def test_agents_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
532 """Invalid --agents exits before trying to open .muse/ (no repo needed)."""
533 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) # no .muse dir
534 result = runner.invoke(cli, ["coord", "shard", "--agents", "0"])
535 assert result.exit_code == 1
536 # Must not say "Repository not found"
537 assert "repository" not in result.output.lower()
538
539
540 # ── JSON schema: new fields ───────────────────────────────────────────────────
541
542
543 class TestShardJsonSchema:
544 def _base_invoke(self, repo: pathlib.Path, agents: str = "2", extra: list[str] | None = None) -> tuple[InvokeResult, MagicMock]:
545 commit = _make_commit_stub("abcdef1234567890abcdef1234567890")
546 sym_map = {"src/a.py": {"f": {}}, "src/b.py": {"g": {}}}
547 manifest = {"src/a.py": "oid1", "src/b.py": "oid2"}
548 args = ["coord", "shard", "--agents", agents, "--json"]
549 if extra:
550 args.extend(extra)
551 with (
552 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
553 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
554 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
555 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
556 ):
557 return runner.invoke(cli, args), commit
558
559 def test_commit_is_full_id(self, repo: pathlib.Path) -> None:
560 result, commit = self._base_invoke(repo)
561 assert result.exit_code == 0
562 data = json.loads(result.output.strip())
563 assert data["commit"] == commit.commit_id
564 assert len(data["commit"]) > 8
565
566 def test_total_files_correct(self, repo: pathlib.Path) -> None:
567 result, _ = self._base_invoke(repo)
568 data = json.loads(result.output.strip())
569 assert data["total_files"] == 2
570
571 def test_total_symbols_correct(self, repo: pathlib.Path) -> None:
572 result, _ = self._base_invoke(repo)
573 data = json.loads(result.output.strip())
574 # sym_map has 1 symbol per file Γ— 2 files
575 assert data["total_symbols"] == 2
576
577 def test_duration_ms_present_and_non_negative(self, repo: pathlib.Path) -> None:
578 result, _ = self._base_invoke(repo)
579 data = json.loads(result.output.strip())
580 assert "duration_ms" in data
581 assert isinstance(data["duration_ms"], float)
582 assert data["duration_ms"] >= 0
583
584 def test_json_is_single_line(self, repo: pathlib.Path) -> None:
585 result, _ = self._base_invoke(repo)
586 lines = [ln for ln in result.output.splitlines() if ln.strip()]
587 assert len(lines) == 1, f"JSON output must be one line, got {len(lines)}"
588
589 def test_all_schema_fields_present(self, repo: pathlib.Path) -> None:
590 result, _ = self._base_invoke(repo)
591 data = json.loads(result.output.strip())
592 required = {
593 "schema", "commit", "agents",
594 "shards_created", "total_files", "total_symbols",
595 "cross_shard_edges", "shards", "duration_ms",
596 }
597 missing = required - data.keys()
598 assert not missing, f"Missing JSON fields: {missing}"
599
600 def test_no_files_case_emits_valid_schema(self, repo: pathlib.Path) -> None:
601 """Empty manifest β†’ shards=[], total_files=0, still valid JSON schema."""
602 commit = _make_commit_stub("abcdef1234567890abcdef1234567890")
603 with (
604 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
605 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value={}),
606 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value={}),
607 ):
608 result = runner.invoke(cli, ["coord", "shard", "--agents", "4", "--json"])
609 assert result.exit_code == 0
610 data = json.loads(result.output.strip())
611 assert data["shards"] == []
612 assert data["total_files"] == 0
613 assert data["total_symbols"] == 0
614 assert "duration_ms" in data
615
616 def test_cross_shard_edges_zero_when_isolated(self, repo: pathlib.Path) -> None:
617 commit = _make_commit_stub()
618 sym_map = {"src/a.py": {"f": {}}, "src/b.py": {"g": {}}}
619 manifest = {"src/a.py": "oid1", "src/b.py": "oid2"}
620 with (
621 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
622 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
623 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
624 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
625 ):
626 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
627 data = json.loads(result.output.strip())
628 assert data["cross_shard_edges"] == 0
629
630 def test_shards_created_capped_at_components(self, repo: pathlib.Path) -> None:
631 """shards_created = min(agents, components) β€” can't exceed file count."""
632 commit = _make_commit_stub()
633 sym_map = {"src/a.py": {"f": {}}} # 1 file β†’ 1 component
634 manifest = {"src/a.py": "oid1"}
635 with (
636 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
637 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
638 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
639 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
640 ):
641 result = runner.invoke(cli, ["coord", "shard", "--agents", "10", "--json"])
642 data = json.loads(result.output.strip())
643 assert data["shards_created"] == 1
644
645
646 # ── Unit: _connected_components edge cases ────────────────────────────────────
647
648
649 class TestConnectedComponentsExtra:
650 def test_cycle_resolved_as_single_component(self) -> None:
651 """A β†’ B β†’ C β†’ A cycle must yield one component of 3 files."""
652 from muse.cli.commands.shard import _connected_components
653 files = ["a.py", "b.py", "c.py"]
654 edges = [("a.py", "b.py"), ("b.py", "c.py"), ("c.py", "a.py")]
655 result = _connected_components(files, edges)
656 assert len(result) == 1
657 assert result[0] == frozenset({"a.py", "b.py", "c.py"})
658
659 def test_star_topology(self) -> None:
660 """Hub β†’ 4 spokes: all connected, 1 component."""
661 from muse.cli.commands.shard import _connected_components
662 files = ["hub.py", "s1.py", "s2.py", "s3.py", "s4.py"]
663 edges = [(f"s{i}.py", "hub.py") for i in range(1, 5)]
664 result = _connected_components(files, edges)
665 assert len(result) == 1
666
667 def test_self_loop_ignored(self) -> None:
668 """A file importing itself produces no cross-edge (target == file_path guard)."""
669 from muse.cli.commands.shard import _connected_components
670 result = _connected_components(["a.py"], [("a.py", "a.py")])
671 assert len(result) == 1
672
673 def test_extra_edge_node_not_in_files_ignored(self) -> None:
674 """An edge referencing a file not in the files list should not crash."""
675 from muse.cli.commands.shard import _connected_components
676 # "ghost.py" is in the edge but not in files β€” adj.setdefault handles it
677 result = _connected_components(["a.py"], [("a.py", "ghost.py")])
678 # a.py is still returned as its own component
679 assert any("a.py" in c for c in result)
680
681
682 # ── Unit: _greedy_partition extra ────────────────────────────────────────────
683
684
685 class TestGreedyPartitionExtra:
686 def test_all_files_accounted_for(self) -> None:
687 """Every file in input components appears in exactly one shard."""
688 from muse.cli.commands.shard import _greedy_partition
689 N = 20
690 comps = [frozenset({f"f{i}.py"}) for i in range(N)]
691 sym_counts = {f"f{i}.py": i + 1 for i in range(N)}
692 shards = _greedy_partition(comps, sym_counts, n_shards=4)
693 all_files = set()
694 for s in shards:
695 assert not (all_files & s), "File appears in more than one shard"
696 all_files |= s
697 expected = {f"f{i}.py" for i in range(N)}
698 assert all_files == expected
699
700 def test_symbol_count_weighting(self) -> None:
701 """Largest component goes to the first shard (LPT first step)."""
702 from muse.cli.commands.shard import _greedy_partition
703 big = frozenset({"big.py"})
704 smalls = [frozenset({f"s{i}.py"}) for i in range(3)]
705 sym_counts = {"big.py": 100, "s0.py": 1, "s1.py": 1, "s2.py": 1}
706 shards = _greedy_partition([big] + smalls, sym_counts, n_shards=2)
707 # big.py is in one shard by itself (100 >> 3*1)
708 big_shard = next(s for s in shards if "big.py" in s)
709 assert big_shard == frozenset({"big.py"})
710
711 def test_single_large_component_into_many_shards(self) -> None:
712 """One big component split into 4 shards β€” all files in first shard."""
713 from muse.cli.commands.shard import _greedy_partition
714 comp = frozenset({"a.py", "b.py", "c.py"})
715 sym_counts = {"a.py": 10, "b.py": 5, "c.py": 3}
716 shards = _greedy_partition([comp], sym_counts, n_shards=4)
717 non_empty = [s for s in shards if s]
718 assert len(non_empty) == 1
719 assert non_empty[0] == comp
720
721
722 # ── Stress tests ──────────────────────────────────────────────────────────────
723
724
725 class TestShardStressExtra:
726 def test_500_isolated_files_16_shards_under_2s(self, repo: pathlib.Path) -> None:
727 """500 isolated files partitioned into 16 shards in < 2 s."""
728 N = 500
729 commit = _make_commit_stub()
730 sym_map = {f"src/mod{i}.py": {f"fn_{i}": {}} for i in range(N)}
731 manifest = {fp: f"oid{i}" for i, fp in enumerate(sym_map)}
732 with (
733 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
734 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
735 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
736 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
737 ):
738 t0 = time.monotonic()
739 result = runner.invoke(cli, ["coord", "shard", "--agents", "16", "--json"])
740 elapsed = time.monotonic() - t0
741 assert result.exit_code == 0
742 assert elapsed < 2.0, f"500 isolated files took {elapsed:.2f}s"
743 data = json.loads(result.output.strip())
744 assert data["total_files"] == N
745 assert data["total_symbols"] == N # 1 sym per file
746
747 def test_200_chain_files_4_shards_cross_edges_correct(self, repo: pathlib.Path) -> None:
748 """Chain graph: mod0β†’mod1→…→mod199, 4 shards β€” cross_shard_edges is exact."""
749 N = 200
750 commit = _make_commit_stub()
751 sym_map = {f"src/mod{i}.py": {f"fn_{i}": {}} for i in range(N)}
752 manifest = {fp: f"oid{i}" for i, fp in enumerate(sym_map)}
753 # A chain: each file imports the next
754 edges = [(f"src/mod{i}.py", f"src/mod{i+1}.py") for i in range(N - 1)]
755 with (
756 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
757 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
758 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
759 patch("muse.cli.commands.shard._build_import_edges", return_value=edges),
760 ):
761 result = runner.invoke(cli, ["coord", "shard", "--agents", "4", "--json"])
762 assert result.exit_code == 0
763 data = json.loads(result.output.strip())
764 # Chain is one big component β†’ 1 shard, no cross-shard edges
765 assert data["shards_created"] == 1
766 assert data["cross_shard_edges"] == 0
767
768 def test_json_compact_with_500_shards(self, repo: pathlib.Path) -> None:
769 """Even with many shards, JSON output is a single compact line."""
770 N = 500
771 commit = _make_commit_stub()
772 sym_map = {f"src/mod{i}.py": {f"fn_{i}": {}} for i in range(N)}
773 manifest = {fp: f"oid{i}" for i, fp in enumerate(sym_map)}
774 with (
775 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
776 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
777 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
778 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
779 ):
780 result = runner.invoke(cli, ["coord", "shard", "--agents", "256", "--json"])
781 assert result.exit_code == 0
782 lines = [ln for ln in result.output.splitlines() if ln.strip()]
783 assert len(lines) == 1
784
785
786 # ── E2E tests ─────────────────────────────────────────────────────────────────
787
788
789 class TestShardE2E:
790 def test_single_file_one_shard_zero_edges(self, repo: pathlib.Path) -> None:
791 commit = _make_commit_stub()
792 sym_map = {"src/only.py": {"fn": {}}}
793 manifest = {"src/only.py": "oid1"}
794 with (
795 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
796 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
797 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
798 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
799 ):
800 result = runner.invoke(cli, ["coord", "shard", "--agents", "4", "--json"])
801 assert result.exit_code == 0
802 data = json.loads(result.output.strip())
803 assert data["shards_created"] == 1
804 assert data["cross_shard_edges"] == 0
805 assert data["total_files"] == 1
806
807 def test_two_disconnected_clusters_zero_cross_edges(self, repo: pathlib.Path) -> None:
808 """Two disconnected clusters into 2 shards β†’ 0 cross-shard edges."""
809 commit = _make_commit_stub()
810 sym_map = {
811 "src/a.py": {"fa": {}}, "src/b.py": {"fb": {}}, # cluster 1
812 "src/c.py": {"fc": {}}, "src/d.py": {"fd": {}}, # cluster 2
813 }
814 manifest = {k: f"oid{i}" for i, k in enumerate(sym_map)}
815 edges = [("src/a.py", "src/b.py"), ("src/c.py", "src/d.py")]
816 with (
817 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
818 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
819 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
820 patch("muse.cli.commands.shard._build_import_edges", return_value=edges),
821 ):
822 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
823 assert result.exit_code == 0
824 data = json.loads(result.output.strip())
825 assert data["shards_created"] == 2
826 assert data["cross_shard_edges"] == 0
827
828 def test_connected_pair_forced_into_two_shards_has_edges(self, repo: pathlib.Path) -> None:
829 """A→B with agents=2 forces a cross-shard edge (both in same component)."""
830 commit = _make_commit_stub()
831 sym_map = {
832 "src/a.py": {"fa": {}}, "src/b.py": {"fb": {}},
833 "src/c.py": {"fc": {}}, # third file so components>1 is possible
834 }
835 manifest = {k: f"oid{i}" for i, k in enumerate(sym_map)}
836 # a→b are connected (1 component), c is isolated (1 component)
837 # agents=2 β†’ 2 shards; a+b are same component β†’ same shard β†’ 0 cross edges
838 edges = [("src/a.py", "src/b.py")]
839 with (
840 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
841 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
842 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
843 patch("muse.cli.commands.shard._build_import_edges", return_value=edges),
844 ):
845 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
846 assert result.exit_code == 0
847 data = json.loads(result.output.strip())
848 # a+b are in the same component, never split β†’ 0 cross-shard edges
849 assert data["cross_shard_edges"] == 0
850
851 def test_text_output_shows_elapsed(self, repo: pathlib.Path) -> None:
852 commit = _make_commit_stub("cafebabe00000000")
853 sym_map = {"src/x.py": {"f": {}}}
854 manifest = {"src/x.py": "oid1"}
855 with (
856 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
857 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
858 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
859 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
860 ):
861 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
862 assert result.exit_code == 0
863 assert "s)" in result.output
864
865 def test_text_output_perfect_isolation_message(self, repo: pathlib.Path) -> None:
866 """When cross_shard_edges == 0, text output says 'Perfect isolation'."""
867 commit = _make_commit_stub()
868 sym_map = {"src/a.py": {"f": {}}}
869 manifest = {"src/a.py": "oid1"}
870 with (
871 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
872 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
873 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
874 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
875 ):
876 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
877 assert result.exit_code == 0
878 assert "Perfect isolation" in result.output
879
880 def test_symbol_count_sum_equals_total_symbols(self, repo: pathlib.Path) -> None:
881 """Sum of shard symbol_counts must equal total_symbols in JSON."""
882 commit = _make_commit_stub()
883 sym_map = {f"src/f{i}.py": {f"fn{j}": {} for j in range(i + 1)} for i in range(5)}
884 manifest = {k: f"oid{i}" for i, k in enumerate(sym_map)}
885 with (
886 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
887 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
888 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
889 patch("muse.cli.commands.shard._build_import_edges", return_value=[]),
890 ):
891 result = runner.invoke(cli, ["coord", "shard", "--agents", "3", "--json"])
892 assert result.exit_code == 0
893 data = json.loads(result.output.strip())
894 assert sum(s["symbol_count"] for s in data["shards"]) == data["total_symbols"]
895
896
897 # ── Stress ────────────────────────────────────────────────────────────────────
898
899
900 class TestShardStress:
901 def test_100_files_8_shards_under_2s(self, repo: pathlib.Path) -> None:
902 n_files = 100
903 commit = _make_commit_stub()
904 sym_map = {f"src/mod{i}.py": {f"fn_{i}": {}, f"cls_{i}": {}} for i in range(n_files)}
905 manifest = {fp: f"oid{i}" for i, fp in enumerate(sym_map)}
906 edges = [(f"src/mod{i}.py", f"src/mod{i+1}.py") for i in range(0, n_files - 1, 5)]
907
908 with (
909 patch("muse.cli.commands.shard.resolve_commit_ref", return_value=commit),
910 patch("muse.cli.commands.shard.get_commit_snapshot_manifest", return_value=manifest),
911 patch("muse.cli.commands.shard.symbols_for_snapshot", return_value=sym_map),
912 patch("muse.cli.commands.shard._build_import_edges", return_value=edges),
913 ):
914 t0 = time.monotonic()
915 result = runner.invoke(cli, ["coord", "shard", "--agents", "8", "--json"])
916 elapsed = time.monotonic() - t0
917
918 assert result.exit_code == 0
919 assert elapsed < 2.0
920 data = json.loads(result.output.strip())
921 assert data["shards_created"] <= 8
922 assert sum(s["symbol_count"] for s in data["shards"]) == n_files * 2
923
924
925 class TestRegisterFlags:
926 def test_default_json_out_is_false(self) -> None:
927 import argparse
928 from muse.cli.commands.shard import register
929 p = argparse.ArgumentParser()
930 subs = p.add_subparsers()
931 register(subs)
932 args = p.parse_args(["shard"])
933 assert args.json_out is False
934
935 def test_json_flag_sets_json_out(self) -> None:
936 import argparse
937 from muse.cli.commands.shard import register
938 p = argparse.ArgumentParser()
939 subs = p.add_subparsers()
940 register(subs)
941 args = p.parse_args(["shard", "--json"])
942 assert args.json_out is True
943
944 def test_j_shorthand_sets_json_out(self) -> None:
945 import argparse
946 from muse.cli.commands.shard import register
947 p = argparse.ArgumentParser()
948 subs = p.add_subparsers()
949 register(subs)
950 args = p.parse_args(["shard", "-j"])
951 assert args.json_out is True