gabriel / muse public
test_directory_dimension.py python
536 lines 21.3 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 125 days ago
1 """TDD — directory-level insight dimension (issue #3).
2
3 Phase 1: helpers in _query.py
4 flat_directory_ops — yields DirectoryRenameOp / insert/delete directory ops
5 touched_directories — set of directories affected by an op list
6 dir_of — extract parent directory from a file path
7
8 Phase 2: muse diff -- directories key in JSON output
9 directory_rename, added dir, deleted dir surfaced as distinct dimension
10
11 Phase 3: muse code hotspots --granularity directory
12 churn counted at directory level instead of symbol level
13
14 Phase 4: muse code entangle --granularity directory
15 co-change pairs at directory granularity
16
17 Phase 5: muse code impact --roll-up-to directory
18 blast radius rolled up to directory level
19 """
20
21 from __future__ import annotations
22
23 import json
24 import pathlib
25 import textwrap
26 from typing import cast
27
28 import pytest
29
30 from muse.domain import DirectoryRenameOp, DomainOp, InsertOp, DeleteOp, PatchOp, ReplaceOp
31
32 # ---------------------------------------------------------------------------
33 # Helpers used across phases
34 # ---------------------------------------------------------------------------
35
36
37 def _insert(address: str, content_summary: str = "") -> InsertOp:
38 return InsertOp(op="insert", address=address, position=None, content_id="", content_summary=content_summary)
39
40
41 def _delete(address: str, content_summary: str = "") -> DeleteOp:
42 return DeleteOp(op="delete", address=address, position=None, content_id="", content_summary=content_summary)
43
44
45 def _rename(from_addr: str, to_addr: str, file_count: int = 2) -> DirectoryRenameOp:
46 return DirectoryRenameOp(op="directory_rename", address=to_addr, from_address=from_addr, file_count=file_count)
47
48
49 def _patch(address: str, children: list[DomainOp] | None = None) -> PatchOp:
50 return PatchOp(
51 op="patch",
52 address=address,
53 child_ops=children or [],
54 child_domain="code",
55 child_summary="",
56 )
57
58
59 def _sym_insert(address: str) -> InsertOp:
60 return InsertOp(op="insert", address=address, position=None, content_id="", content_summary="added function")
61
62
63 # ---------------------------------------------------------------------------
64 # Phase 1A: flat_directory_ops
65 # ---------------------------------------------------------------------------
66
67
68 class TestFlatDirectoryOps:
69 """flat_directory_ops yields directory-level ops and ignores symbol/file ops."""
70
71 def test_yields_directory_rename(self) -> None:
72 from muse.plugins.code._query import flat_directory_ops
73 ops: list[DomainOp] = [_rename("src/old", "src/new")]
74 result = list(flat_directory_ops(ops))
75 assert len(result) == 1
76 assert result[0]["op"] == "directory_rename"
77
78 def test_yields_directory_insert(self) -> None:
79 from muse.plugins.code._query import flat_directory_ops
80 ops: list[DomainOp] = [_insert("src/newdir/", "directory: src/newdir/")]
81 result = list(flat_directory_ops(ops))
82 assert len(result) == 1
83
84 def test_yields_directory_delete(self) -> None:
85 from muse.plugins.code._query import flat_directory_ops
86 ops: list[DomainOp] = [_delete("src/olddir/", "directory: src/olddir/")]
87 result = list(flat_directory_ops(ops))
88 assert len(result) == 1
89
90 def test_skips_symbol_level_patch_children(self) -> None:
91 from muse.plugins.code._query import flat_directory_ops
92 ops: list[DomainOp] = [
93 _patch("src/billing.py", [_sym_insert("src/billing.py::compute_total")]),
94 ]
95 result = list(flat_directory_ops(ops))
96 assert result == []
97
98 def test_skips_plain_file_insert(self) -> None:
99 from muse.plugins.code._query import flat_directory_ops
100 ops: list[DomainOp] = [_insert("src/billing.py", "added file")]
101 result = list(flat_directory_ops(ops))
102 assert result == []
103
104 def test_mixed_ops_only_dir(self) -> None:
105 from muse.plugins.code._query import flat_directory_ops
106 ops: list[DomainOp] = [
107 _rename("api/v1", "api/v2"),
108 _patch("src/billing.py", [_sym_insert("src/billing.py::fn")]),
109 _insert("src/utils.py", "added file"),
110 _insert("tests/", "directory: tests/"),
111 ]
112 result = list(flat_directory_ops(ops))
113 assert len(result) == 2 # rename + tests/ insert
114 op_types = {r["op"] for r in result}
115 assert "directory_rename" in op_types
116
117 def test_empty_ops(self) -> None:
118 from muse.plugins.code._query import flat_directory_ops
119 assert list(flat_directory_ops([])) == []
120
121 def test_rename_carries_from_address(self) -> None:
122 from muse.plugins.code._query import flat_directory_ops
123 ops: list[DomainOp] = [_rename("src/auth_old", "src/auth", file_count=5)]
124 result = list(flat_directory_ops(ops))
125 assert result[0].get("from_address") == "src/auth_old"
126 assert result[0]["address"] == "src/auth"
127 assert result[0].get("file_count") == 5
128
129
130 # ---------------------------------------------------------------------------
131 # Phase 1B: touched_directories
132 # ---------------------------------------------------------------------------
133
134
135 class TestTouchedDirectories:
136 """touched_directories returns the set of directories whose files changed."""
137
138 def test_single_file_returns_its_parent(self) -> None:
139 from muse.plugins.code._query import touched_directories
140 ops: list[DomainOp] = [
141 _patch("src/billing.py", [_sym_insert("src/billing.py::fn")]),
142 ]
143 dirs = touched_directories(ops)
144 assert "src" in dirs
145
146 def test_root_level_file_returns_dot_or_empty(self) -> None:
147 from muse.plugins.code._query import touched_directories
148 ops: list[DomainOp] = [
149 _patch("main.py", [_sym_insert("main.py::fn")]),
150 ]
151 dirs = touched_directories(ops)
152 # root-level files belong to "." (POSIX convention)
153 assert "." in dirs
154
155 def test_multiple_files_same_dir_counted_once(self) -> None:
156 from muse.plugins.code._query import touched_directories
157 ops: list[DomainOp] = [
158 _patch("src/billing.py", [_sym_insert("src/billing.py::fn")]),
159 _patch("src/auth.py", [_sym_insert("src/auth.py::validate")]),
160 ]
161 dirs = touched_directories(ops)
162 assert dirs.count("src") == 1 if isinstance(dirs, list) else len([d for d in dirs if d == "src"]) == 1
163
164 def test_returns_frozenset(self) -> None:
165 from muse.plugins.code._query import touched_directories
166 ops: list[DomainOp] = [
167 _patch("src/a.py", [_sym_insert("src/a.py::fn")]),
168 ]
169 result = touched_directories(ops)
170 assert isinstance(result, frozenset)
171
172 def test_nested_path_returns_immediate_parent(self) -> None:
173 from muse.plugins.code._query import touched_directories
174 ops: list[DomainOp] = [
175 _patch("muse/cli/commands/cat.py", [_sym_insert("muse/cli/commands/cat.py::run")]),
176 ]
177 dirs = touched_directories(ops)
178 assert "muse/cli/commands" in dirs
179
180 def test_directory_rename_op_adds_both_dirs(self) -> None:
181 from muse.plugins.code._query import touched_directories
182 ops: list[DomainOp] = [_rename("api/v1", "api/v2")]
183 dirs = touched_directories(ops)
184 assert "api/v1" in dirs
185 assert "api/v2" in dirs
186
187 def test_empty_ops_returns_empty(self) -> None:
188 from muse.plugins.code._query import touched_directories
189 assert touched_directories([]) == frozenset()
190
191 def test_file_without_symbol_children_not_counted(self) -> None:
192 from muse.plugins.code._query import touched_directories
193 ops: list[DomainOp] = [
194 _patch("src/billing.py", []), # PatchOp with no children — non-semantic
195 ]
196 dirs = touched_directories(ops)
197 assert "src" not in dirs
198
199
200 # ---------------------------------------------------------------------------
201 # Phase 1C: dir_of
202 # ---------------------------------------------------------------------------
203
204
205 class TestDirOf:
206 """dir_of extracts the immediate parent directory from a file path."""
207
208 def test_nested_path(self) -> None:
209 from muse.plugins.code._query import dir_of
210 assert dir_of("src/billing.py") == "src"
211
212 def test_deeply_nested(self) -> None:
213 from muse.plugins.code._query import dir_of
214 assert dir_of("muse/cli/commands/cat.py") == "muse/cli/commands"
215
216 def test_root_level_file(self) -> None:
217 from muse.plugins.code._query import dir_of
218 assert dir_of("main.py") == "."
219
220 def test_directory_address_trailing_slash(self) -> None:
221 from muse.plugins.code._query import dir_of
222 assert dir_of("src/") == "src"
223
224 def test_no_extension_file(self) -> None:
225 from muse.plugins.code._query import dir_of
226 assert dir_of("src/Makefile") == "src"
227
228
229 # ---------------------------------------------------------------------------
230 # Phase 2: muse diff -- directory dimension in JSON output
231 # ---------------------------------------------------------------------------
232
233
234 from tests.cli_test_helper import CliRunner
235
236 cli = None
237 runner = CliRunner()
238
239
240 def _make_diff_repo(tmp_path: pathlib.Path) -> pathlib.Path:
241 """Init a repo and make two commits with a directory rename."""
242 from muse.core.object_store import write_object
243 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
244 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
245 from muse.core.types import long_id, blob_id
246 from muse.core.paths import muse_dir, ref_path
247 import datetime
248
249 dot = muse_dir(tmp_path)
250 for d in ("commits", "snapshots", "objects", "refs/heads"):
251 (dot / d).mkdir(parents=True, exist_ok=True)
252 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
253 (dot / "repo.json").write_text(json.dumps({"repo_id": "dir-dim-test", "domain": "code"}), encoding="utf-8")
254 return tmp_path
255
256
257 class TestDiffDirectories:
258 """muse diff --json includes a ``directories`` key with structural changes."""
259
260 def test_json_has_directories_key(self, tmp_path: pathlib.Path) -> None:
261 _make_diff_repo(tmp_path)
262 result = runner.invoke(
263 cli, ["diff", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}
264 )
265 assert result.exit_code == 0
266 data = json.loads(result.output)
267 assert "directories" in data
268
269 def test_directories_key_has_expected_subkeys(self, tmp_path: pathlib.Path) -> None:
270 _make_diff_repo(tmp_path)
271 result = runner.invoke(
272 cli, ["diff", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}
273 )
274 data = json.loads(result.output)
275 dirs = data["directories"]
276 assert "added" in dirs
277 assert "deleted" in dirs
278 assert "renamed" in dirs
279
280 def test_clean_repo_has_empty_directories(self, tmp_path: pathlib.Path) -> None:
281 _make_diff_repo(tmp_path)
282 result = runner.invoke(
283 cli, ["diff", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}
284 )
285 data = json.loads(result.output)
286 dirs = data["directories"]
287 assert dirs["added"] == []
288 assert dirs["deleted"] == []
289 assert dirs["renamed"] == {}
290
291
292 # ---------------------------------------------------------------------------
293 # Phase 3: muse code hotspots --granularity directory
294 # ---------------------------------------------------------------------------
295
296
297 class TestHotspotsDirectoryGranularity:
298 """muse code hotspots --granularity directory counts churn at dir level."""
299
300 def test_granularity_flag_accepted(self, tmp_path: pathlib.Path) -> None:
301 import argparse
302 from muse.cli.commands.hotspots import register
303 p = argparse.ArgumentParser()
304 subs = p.add_subparsers(dest="cmd")
305 register(subs)
306 args = p.parse_args(["hotspots", "--granularity", "directory"])
307 assert args.granularity == "directory"
308
309 def test_granularity_default_is_symbol(self, tmp_path: pathlib.Path) -> None:
310 import argparse
311 from muse.cli.commands.hotspots import register
312 p = argparse.ArgumentParser()
313 subs = p.add_subparsers(dest="cmd")
314 register(subs)
315 args = p.parse_args(["hotspots"])
316 assert args.granularity == "symbol"
317
318 def test_directory_granularity_json_addresses_have_no_colons(
319 self, tmp_path: pathlib.Path
320 ) -> None:
321 """Directory addresses are plain paths, never contain '::'."""
322 from tests.cli_test_helper import CliRunner as CR
323 r = CR()
324 from muse.core.object_store import write_object
325 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
326 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
327 from muse.core.types import long_id, blob_id
328 from muse.core.paths import muse_dir, ref_path
329 import datetime
330
331 dot = muse_dir(tmp_path)
332 for d in ("commits", "snapshots", "objects", "refs/heads"):
333 (dot / d).mkdir(parents=True, exist_ok=True)
334 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
335 (dot / "repo.json").write_text(json.dumps({"repo_id": "hs-dir-test", "domain": "code"}), encoding="utf-8")
336
337 result = r.invoke(
338 None,
339 ["code", "hotspots", "--granularity", "directory", "--json"],
340 env={"MUSE_REPO_ROOT": str(tmp_path)},
341 )
342 assert result.exit_code == 0
343 data = json.loads(result.output)
344 assert "hotspots" in data
345 for entry in data["hotspots"]:
346 assert "::" not in entry["address"], f"directory address contains '::': {entry['address']}"
347
348 def test_directory_granularity_json_has_granularity_field(
349 self, tmp_path: pathlib.Path
350 ) -> None:
351 from tests.cli_test_helper import CliRunner as CR
352 from muse.core.paths import muse_dir
353 dot = muse_dir(tmp_path)
354 for d in ("commits", "snapshots", "objects", "refs/heads"):
355 (dot / d).mkdir(parents=True, exist_ok=True)
356 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
357 (dot / "repo.json").write_text(json.dumps({"repo_id": "hs-dir-test2", "domain": "code"}), encoding="utf-8")
358
359 r = CR()
360 result = r.invoke(
361 None,
362 ["code", "hotspots", "--granularity", "directory", "--json"],
363 env={"MUSE_REPO_ROOT": str(tmp_path)},
364 )
365 assert result.exit_code == 0
366 data = json.loads(result.output)
367 assert data.get("granularity") == "directory"
368
369 def test_symbol_granularity_json_addresses_contain_colons(
370 self, tmp_path: pathlib.Path
371 ) -> None:
372 """Default (symbol) granularity addresses are 'file.py::symbol'."""
373 from tests.cli_test_helper import CliRunner as CR
374 from muse.core.paths import muse_dir
375 dot = muse_dir(tmp_path)
376 for d in ("commits", "snapshots", "objects", "refs/heads"):
377 (dot / d).mkdir(parents=True, exist_ok=True)
378 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
379 (dot / "repo.json").write_text(json.dumps({"repo_id": "hs-sym-test", "domain": "code"}), encoding="utf-8")
380
381 r = CR()
382 result = r.invoke(
383 None,
384 ["code", "hotspots", "--json"],
385 env={"MUSE_REPO_ROOT": str(tmp_path)},
386 )
387 assert result.exit_code == 0
388 data = json.loads(result.output)
389 assert data.get("granularity") == "symbol"
390
391
392 # ---------------------------------------------------------------------------
393 # Phase 4: muse code entangle --granularity directory
394 # ---------------------------------------------------------------------------
395
396
397 class TestEntangleDirectoryGranularity:
398 """muse code entangle --granularity directory finds directories that
399 always change together."""
400
401 def test_granularity_flag_accepted(self) -> None:
402 import argparse
403 from muse.cli.commands.entangle import register
404 p = argparse.ArgumentParser()
405 subs = p.add_subparsers(dest="cmd")
406 register(subs)
407 args = p.parse_args(["entangle", "--granularity", "directory"])
408 assert args.granularity == "directory"
409
410 def test_granularity_default_is_symbol(self) -> None:
411 import argparse
412 from muse.cli.commands.entangle import register
413 p = argparse.ArgumentParser()
414 subs = p.add_subparsers(dest="cmd")
415 register(subs)
416 args = p.parse_args(["entangle"])
417 assert args.granularity == "symbol"
418
419 def test_directory_granularity_json_pairs_have_no_colons(
420 self, tmp_path: pathlib.Path
421 ) -> None:
422 from tests.cli_test_helper import CliRunner as CR
423 from muse.core.paths import muse_dir
424 dot = muse_dir(tmp_path)
425 for d in ("commits", "snapshots", "objects", "refs/heads"):
426 (dot / d).mkdir(parents=True, exist_ok=True)
427 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
428 (dot / "repo.json").write_text(json.dumps({"repo_id": "ent-dir-test", "domain": "code"}), encoding="utf-8")
429
430 r = CR()
431 result = r.invoke(
432 None,
433 ["code", "entangle", "--granularity", "directory", "--json"],
434 env={"MUSE_REPO_ROOT": str(tmp_path)},
435 )
436 assert result.exit_code == 0
437 data = json.loads(result.output)
438 assert "pairs" in data
439 for pair in data["pairs"]:
440 assert "::" not in pair["dir_a"]
441 assert "::" not in pair["dir_b"]
442
443 def test_directory_granularity_json_has_granularity_field(
444 self, tmp_path: pathlib.Path
445 ) -> None:
446 from tests.cli_test_helper import CliRunner as CR
447 from muse.core.paths import muse_dir
448 dot = muse_dir(tmp_path)
449 for d in ("commits", "snapshots", "objects", "refs/heads"):
450 (dot / d).mkdir(parents=True, exist_ok=True)
451 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
452 (dot / "repo.json").write_text(json.dumps({"repo_id": "ent-dir-test2", "domain": "code"}), encoding="utf-8")
453
454 r = CR()
455 result = r.invoke(
456 None,
457 ["code", "entangle", "--granularity", "directory", "--json"],
458 env={"MUSE_REPO_ROOT": str(tmp_path)},
459 )
460 assert result.exit_code == 0
461 data = json.loads(result.output)
462 assert data.get("granularity") == "directory"
463
464
465 # ---------------------------------------------------------------------------
466 # Phase 5: muse code impact --roll-up-to directory
467 # ---------------------------------------------------------------------------
468
469
470 class TestImpactDirectoryRollup:
471 """muse code impact --roll-up-to directory aggregates blast radius by dir."""
472
473 def test_roll_up_to_flag_accepted(self) -> None:
474 import argparse
475 from muse.cli.commands.impact import register
476 p = argparse.ArgumentParser()
477 subs = p.add_subparsers(dest="cmd")
478 register(subs)
479 args = p.parse_args(["impact", "src/billing.py::compute_total", "--roll-up-to", "directory"])
480 assert args.roll_up_to == "directory"
481
482 def test_roll_up_to_default_is_none(self) -> None:
483 import argparse
484 from muse.cli.commands.impact import register
485 p = argparse.ArgumentParser()
486 subs = p.add_subparsers(dest="cmd")
487 register(subs)
488 args = p.parse_args(["impact", "src/billing.py::compute_total"])
489 assert args.roll_up_to is None
490
491 def test_directory_rollup_json_has_directory_blast_radius(
492 self, tmp_path: pathlib.Path
493 ) -> None:
494 from tests.cli_test_helper import CliRunner as CR
495 from muse.core.paths import muse_dir
496 dot = muse_dir(tmp_path)
497 for d in ("commits", "snapshots", "objects", "refs/heads"):
498 (dot / d).mkdir(parents=True, exist_ok=True)
499 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
500 (dot / "repo.json").write_text(json.dumps({"repo_id": "imp-dir-test", "domain": "code"}), encoding="utf-8")
501
502 r = CR()
503 result = r.invoke(
504 None,
505 ["code", "impact", "src/billing.py::compute_total",
506 "--roll-up-to", "directory", "--json"],
507 env={"MUSE_REPO_ROOT": str(tmp_path)},
508 )
509 # May exit 1 (symbol not found in empty repo) — just verify schema when successful
510 if result.exit_code == 0:
511 data = json.loads(result.output)
512 assert "directory_blast_radius" in data
513
514 def test_directory_rollup_addresses_have_no_colons(
515 self, tmp_path: pathlib.Path
516 ) -> None:
517 """directory_blast_radius keys are plain directory paths, never 'file::sym'."""
518 from tests.cli_test_helper import CliRunner as CR
519 from muse.core.paths import muse_dir
520 dot = muse_dir(tmp_path)
521 for d in ("commits", "snapshots", "objects", "refs/heads"):
522 (dot / d).mkdir(parents=True, exist_ok=True)
523 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
524 (dot / "repo.json").write_text(json.dumps({"repo_id": "imp-dir-test2", "domain": "code"}), encoding="utf-8")
525
526 r = CR()
527 result = r.invoke(
528 None,
529 ["code", "impact", "src/billing.py::compute_total",
530 "--roll-up-to", "directory", "--json"],
531 env={"MUSE_REPO_ROOT": str(tmp_path)},
532 )
533 if result.exit_code == 0:
534 data = json.loads(result.output)
535 for dir_path in data.get("directory_blast_radius", {}).keys():
536 assert "::" not in dir_path
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 125 days ago