gabriel / muse public
entangle.py python
804 lines 29.4 KB
Raw
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9 Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump… Human 10 days ago
1 """muse code entangle — hidden symbol-pair entanglement detector.
2
3 ``muse code coupling`` works at the file level. ``muse code entangle`` goes
4 one level deeper and works at the **symbol level**: it finds *pairs of
5 symbols* that consistently change in the same commit but have **no structural
6 import or call-graph link** between them.
7
8 These are the hidden "keep-in-sync" relationships that nobody documented —
9 the ones that cause mysterious bugs when one side is updated and the other is
10 forgotten.
11
12 Why this is impossible in Git
13 ------------------------------
14 Git sees ``billing.py`` and ``serializers.py`` changed in the same commit.
15 It has no way to say *which functions* changed, so it cannot report
16 symbol-pair co-change.
17
18 Muse stores a ``structured_delta`` with every commit. Each delta records
19 every symbol operation — insert, delete, replace — with its precise address.
20 ``entangle`` mines this history to surface the statistical signal that file
21 diffing cannot see.
22
23 Exclusions
24 ----------
25 - Import pseudo-symbols (``::import::``) are excluded.
26 - Commits touching more than ``_MAX_SYMBOLS_PER_COMMIT`` distinct symbols are
27 skipped — they are mass-refactors that produce O(N²) noise.
28 - The same-file constraint can be relaxed with ``--include-same-file``.
29 - Results where either symbol is in a test file are surfaced with a
30 ``[test]`` badge.
31
32 Structural-link check
33 ---------------------
34 Two symbols are considered **structurally linked** if the file that
35 contains *symbol A* has an import record pointing to the module/file of
36 *symbol B*, or vice versa. This check is intentionally conservative —
37 it only looks at top-level import symbols in the current snapshot. A
38 pair that is flagged as "entangled" despite an obvious import may be using
39 an indirect import chain; that is still interesting to know.
40
41 Usage::
42
43 muse code entangle
44 muse code entangle --top 20
45 muse code entangle --min-rate 0.5 # only pairs that co-changed > 50 % of the time
46 muse code entangle --symbol billing.py::Invoice.compute_total
47 muse code entangle --include-same-file # include pairs in the same file
48 muse code entangle --since HEAD~30
49 muse code entangle --json
50
51 Output::
52
53 Symbol entanglement — HEAD (47 commits · 3 entangled pairs)
54
55 # SYMBOL A ↔ SYMBOL B RATE CO-CHANGES
56 1 billing.py::Invoice.compute_total ↔ serializers.py::to_json 91% 10 / 11
57 2 auth.py::verify_identity ↔ middleware.py::check_token 80% 8 / 10
58 3 models.py::User.save ↔ events.py::emit_user_event 75% 6 / 8 [cross-file, no import link]
59
60 ⚠️ These pairs change together but have no structural import link.
61 Consider adding documentation, a shared interface, or an explicit test.
62
63 JSON output (--json)::
64
65 {
66 "ref": "HEAD",
67 "commits_analysed": 47,
68 "truncated": false,
69 "filters": { "min_rate": 0.5, "min_co_changes": 2, "symbol": null, "include_same_file": false },
70 "pairs": [
71 {
72 "symbol_a": "billing.py::Invoice.compute_total",
73 "symbol_b": "serializers.py::to_json",
74 "file_a": "billing.py",
75 "file_b": "serializers.py",
76 "same_file": false,
77 "structurally_linked": false,
78 "co_changes": 10,
79 "commits_both_active": 11,
80 "co_change_rate": 0.91,
81 "a_in_test": false,
82 "b_in_test": false
83 }
84 ]
85 }
86 """
87
88 import argparse
89 import ast
90 import json
91 import logging
92 import pathlib
93 import re
94 import sys
95 from typing import TypedDict
96
97 from muse.core.envelope import EnvelopeJson, make_envelope
98 from muse.core.errors import ExitCode
99 from muse.core.repo import require_repo
100 from muse.core.timing import start_timer
101 from muse.core.types import (
102 JsonValue,
103 Manifest,
104 )
105 from muse.core.refs import read_current_branch
106 from muse.core.commits import resolve_commit_ref
107 from muse.core.snapshots import get_commit_snapshot_manifest
108 from muse.core.symbol_cache import load_symbol_cache
109 from muse.domain import DomainOp
110
111 type _ImportMap = dict[str, set[str]]
112 type _CounterMap = dict[str, int]
113 type _JsonFilters = dict[str, JsonValue]
114 from muse.plugins.code._query import (
115 dir_of,
116 flat_symbol_ops,
117 symbols_for_snapshot,
118 touched_directories,
119 walk_commits_bfs,
120 )
121 from muse.core.validation import clamp_int, sanitize_display
122
123 logger = logging.getLogger(__name__)
124
125 # ── Constants ──────────────────────────────────────────────────────────────────
126
127 _DEFAULT_TOP = 20
128 _DEFAULT_MIN_CO_CHANGES = 2
129 _DEFAULT_MIN_RATE = 0.0
130 _DEFAULT_MAX_COMMITS = 10_000
131
132 # Skip commits with too many distinct symbols — they're mass-refactors.
133 _MAX_SYMBOLS_PER_COMMIT = 200
134
135 # Test-file heuristics.
136 _TEST_RE: tuple[re.Pattern[str], ...] = (
137 re.compile(r"(^|/)test_"),
138 re.compile(r"_test\.py$"),
139 re.compile(r"(^|/)tests/"),
140 re.compile(r"(^|/)spec/"),
141 re.compile(r"(^|/)conftest\.py$"),
142 )
143
144 def _is_test_file(path: str) -> bool:
145 return any(p.search(path) for p in _TEST_RE)
146
147 # ── TypedDict ──────────────────────────────────────────────────────────────────
148
149 class _EntangledPair(TypedDict):
150 """One co-change pair detected by the entangle analysis.
151
152 Nested inside the ``pairs`` list of :class:`_EntangleOutputJson`.
153
154 Fields
155 ------
156 symbol_a Full address of the first symbol (``file.py::Name``).
157 symbol_b Full address of the second symbol (``file.py::Name``).
158 file_a Source file containing ``symbol_a``.
159 file_b Source file containing ``symbol_b``.
160 same_file True when both symbols live in the same file (intra-file coupling).
161 structurally_linked True when a direct import or call edge connects the two
162 symbols (explains expected co-change).
163 co_changes Number of commits in which both symbols changed together.
164 commits_both_active Number of commits in which both symbols existed at all
165 (the denominator for ``co_change_rate``).
166 co_change_rate ``co_changes / commits_both_active`` — higher means
167 tighter coupling. Range [0, 1].
168 a_in_test True when ``symbol_a`` lives in a test file.
169 b_in_test True when ``symbol_b`` lives in a test file.
170 """
171
172 symbol_a: str
173 symbol_b: str
174 file_a: str
175 file_b: str
176 same_file: bool
177 structurally_linked: bool
178 co_changes: int
179 commits_both_active: int
180 co_change_rate: float
181 a_in_test: bool
182 b_in_test: bool
183
184 class _DirectoryPair(TypedDict):
185 """One co-change directory pair detected by directory-granularity entangle."""
186
187 dir_a: str
188 dir_b: str
189 co_changes: int
190 co_change_rate: float
191
192
193 def _collect_directory_pairs(
194 root: pathlib.Path,
195 head_commit_id: str,
196 stop_at: str | None,
197 max_commits: int,
198 ) -> tuple[dict[tuple[str, str], int], dict[str, int], int, bool]:
199 """Single BFS pass: count directory-pair co-changes.
200
201 Returns:
202 co_changes — ``(dir_a, dir_b) → commit count`` (a < b lexicographically)
203 active — ``dir → commit count where the dir was touched``
204 commits_analysed, truncated
205 """
206 commits, truncated = walk_commits_bfs(
207 root, head_commit_id, max_commits, stop_at_commit_id=stop_at
208 )
209
210 co_changes: dict[tuple[str, str], int] = {}
211 active: _CounterMap = {}
212
213 for commit in commits:
214 if commit.structured_delta is None:
215 continue
216 ops: list[DomainOp] = commit.structured_delta["ops"]
217 dirs = list(touched_directories(ops))
218
219 unique = list(dict.fromkeys(dirs))
220
221 for d in unique:
222 active[d] = active.get(d, 0) + 1
223
224 for i in range(len(unique)):
225 for j in range(i + 1, len(unique)):
226 a, b = unique[i], unique[j]
227 if a == b:
228 continue
229 pair: tuple[str, str] = (a, b) if a <= b else (b, a)
230 co_changes[pair] = co_changes.get(pair, 0) + 1
231
232 return co_changes, active, len(commits), truncated
233
234
235 def _build_directory_pairs(
236 co_changes: dict[tuple[str, str], int],
237 active: _CounterMap,
238 min_co_changes: int,
239 min_rate: float,
240 top: int,
241 ) -> list[_DirectoryPair]:
242 """Filter, score, and sort the directory co-change pairs."""
243 pairs: list[_DirectoryPair] = []
244
245 for (a, b), count in co_changes.items():
246 if count < min_co_changes:
247 continue
248
249 active_a = active.get(a, count)
250 active_b = active.get(b, count)
251 both_active = min(active_a, active_b)
252 rate = count / both_active if both_active > 0 else 0.0
253
254 if rate < min_rate:
255 continue
256
257 pairs.append(_DirectoryPair(
258 dir_a=a,
259 dir_b=b,
260 co_changes=count,
261 co_change_rate=round(rate, 4),
262 ))
263
264 pairs.sort(key=lambda p: (-p["co_change_rate"], -p["co_changes"], p["dir_a"]))
265 return pairs[:top]
266
267
268 class _EntangleOutputJson(EnvelopeJson):
269 """JSON output for ``muse code entangle --json``.
270
271 Fields
272 ------
273 ref Branch or ref at which the analysis was performed.
274 commits_analysed Total commits walked during the BFS pass.
275 truncated True if the scan hit --max-commits before exhausting history.
276 filters Dict of active filter values (min_rate, min_co_changes, etc.).
277 pairs List of entangled symbol-pair dicts.
278 """
279
280 ref: str
281 commits_analysed: int
282 truncated: bool
283 filters: _JsonFilters
284 pairs: list[_EntangledPair]
285
286 # ── Helpers ────────────────────────────────────────────────────────────────────
287
288 def _module_name_from_path(file_path: str) -> str:
289 """Convert a file path to a rough Python module name.
290
291 ``muse/core/store.py`` → ``muse.core.store``
292 ``billing.py`` → ``billing``
293 """
294 stem = file_path.removesuffix(".py").removesuffix(".pyi")
295 return stem.replace("/", ".").replace("\\", ".")
296
297 def _build_import_map(
298 root: pathlib.Path,
299 manifest: Manifest,
300 ) -> _ImportMap:
301 """Return ``{file_path: {imported_module_name, ...}}`` from the snapshot.
302
303 We extract import pseudo-symbols from the symbol cache / AST parser.
304 These records have ``kind == "import"`` and ``qualified_name`` set to the
305 imported module name (e.g. ``muse.core.store`` or just ``store``).
306 """
307 cache = load_symbol_cache(root)
308 all_trees = symbols_for_snapshot(root, manifest, cache=cache)
309
310 import_map: _ImportMap = {}
311 for file_path, tree in all_trees.items():
312 mods: set[str] = set()
313 for rec in tree.values():
314 if rec.get("kind") == "import":
315 qn: str = rec.get("qualified_name") or rec.get("name") or ""
316 if qn:
317 mods.add(qn)
318 # Also record the top-level package for coarse matching.
319 mods.add(qn.split(".")[0])
320 import_map[file_path] = mods
321
322 return import_map
323
324 def _are_structurally_linked(
325 file_a: str,
326 file_b: str,
327 import_map: _ImportMap,
328 ) -> bool:
329 """Return True if either file imports the other (even partially).
330
331 We check whether any module name in the import set of file_a resembles
332 the module path of file_b, or vice versa. We use a suffix check rather
333 than exact match to handle aliased or re-exported imports.
334 """
335 mod_a = _module_name_from_path(file_a)
336 mod_b = _module_name_from_path(file_b)
337
338 imports_of_a = import_map.get(file_a, set())
339 imports_of_b = import_map.get(file_b, set())
340
341 # file_a imports file_b?
342 for imp in imports_of_a:
343 if mod_b.endswith(imp) or imp.endswith(mod_b) or imp == mod_b.split(".")[-1]:
344 return True
345 # file_b imports file_a?
346 for imp in imports_of_b:
347 if mod_a.endswith(imp) or imp.endswith(mod_a) or imp == mod_a.split(".")[-1]:
348 return True
349 return False
350
351 # ── Core algorithm ─────────────────────────────────────────────────────────────
352
353 def _collect_symbol_pairs(
354 root: pathlib.Path,
355 head_commit_id: str,
356 stop_at: str | None,
357 max_commits: int,
358 include_same_file: bool,
359 ) -> tuple[
360 dict[tuple[str, str], int], # co-change counts
361 dict[str, int], # how many commits each symbol was active in
362 int, # commits_analysed
363 bool, # truncated
364 ]:
365 """Single BFS pass: count symbol-pair co-changes.
366
367 Returns:
368 co_changes — ``(addr_a, addr_b) → commit count`` (a < b lexicographically)
369 active — ``addr → commit count where the symbol changed``
370 commits_analysed, truncated
371 """
372 commits, truncated = walk_commits_bfs(
373 root, head_commit_id, max_commits, stop_at_commit_id=stop_at
374 )
375
376 co_changes: dict[tuple[str, str], int] = {}
377 active: _CounterMap = {}
378
379 for commit in commits:
380 if commit.structured_delta is None:
381 continue
382 ops: list[DomainOp] = commit.structured_delta["ops"]
383
384 # Collect distinct symbol addresses changed in this commit,
385 # excluding import pseudo-symbols.
386 changed: list[str] = []
387 for op in flat_symbol_ops(ops):
388 addr: str = op["address"]
389 if "::import::" not in addr:
390 changed.append(addr)
391
392 # Skip mass-refactor commits.
393 if len(changed) > _MAX_SYMBOLS_PER_COMMIT:
394 continue
395
396 # Deduplicate within this commit (a symbol may appear >1 ops).
397 unique = list(dict.fromkeys(changed))
398
399 # Track per-symbol activity count.
400 for addr in unique:
401 active[addr] = active.get(addr, 0) + 1
402
403 # Count co-changing pairs.
404 for i in range(len(unique)):
405 for j in range(i + 1, len(unique)):
406 a, b = unique[i], unique[j]
407 # Canonical ordering so we always use the same dict key.
408 pair: tuple[str, str] = (a, b) if a <= b else (b, a)
409
410 fa = a.split("::")[0]
411 fb = b.split("::")[0]
412 if not include_same_file and fa == fb:
413 continue
414
415 co_changes[pair] = co_changes.get(pair, 0) + 1
416
417 return co_changes, active, len(commits), truncated
418
419 # ── Scoring + filtering ────────────────────────────────────────────────────────
420
421 def _build_pairs(
422 co_changes: dict[tuple[str, str], int],
423 active: _CounterMap,
424 import_map: _ImportMap,
425 min_co_changes: int,
426 min_rate: float,
427 symbol_filter: str | None,
428 top: int,
429 include_same_file: bool,
430 ) -> list[_EntangledPair]:
431 """Filter, score, and sort the co-change pairs."""
432 pairs: list[_EntangledPair] = []
433
434 for (a, b), count in co_changes.items():
435 if count < min_co_changes:
436 continue
437
438 fa = a.split("::")[0]
439 fb = b.split("::")[0]
440
441 # --symbol filter: only show pairs involving the requested symbol.
442 if symbol_filter is not None and symbol_filter not in (a, b):
443 continue
444
445 # How many commits were both symbols active?
446 # Use the lower of the two active counts as the "opportunity window".
447 active_a = active.get(a, count)
448 active_b = active.get(b, count)
449 both_active = min(active_a, active_b)
450 rate = count / both_active if both_active > 0 else 0.0
451
452 if rate < min_rate:
453 continue
454
455 linked = _are_structurally_linked(fa, fb, import_map)
456 # We only surface *unlinked* pairs — that's the entanglement signal.
457 # Unless the user provides --symbol to inspect a specific pair.
458 if linked and symbol_filter is None:
459 continue
460
461 pairs.append(_EntangledPair(
462 symbol_a=a,
463 symbol_b=b,
464 file_a=fa,
465 file_b=fb,
466 same_file=(fa == fb),
467 structurally_linked=linked,
468 co_changes=count,
469 commits_both_active=both_active,
470 co_change_rate=round(rate, 4),
471 a_in_test=_is_test_file(fa),
472 b_in_test=_is_test_file(fb),
473 ))
474
475 # Sort: highest co_change_rate first, then co_changes count, then address.
476 pairs.sort(key=lambda p: (-p["co_change_rate"], -p["co_changes"], p["symbol_a"]))
477 return pairs[:top]
478
479 # ── Formatters ─────────────────────────────────────────────────────────────────
480
481 def _print_table(
482 pairs: list[_EntangledPair],
483 ref: str,
484 commits_analysed: int,
485 truncated: bool,
486 since: str | None,
487 ) -> None:
488 scope = f"{since}..{ref}" if since else ref
489 trunc = " ⚠️ truncated" if truncated else ""
490 print(
491 f"\nSymbol entanglement — {scope}"
492 f" ({commits_analysed} commits · {len(pairs)} entangled pair(s){trunc})"
493 )
494 print("")
495
496 if not pairs:
497 print(" (no entangled symbol pairs found)")
498 print(
499 "\n All co-changing symbol pairs have a structural import link "
500 "— no hidden entanglement detected."
501 )
502 return
503
504 max_a = max(len(p["symbol_a"]) for p in pairs)
505 max_b = max(len(p["symbol_b"]) for p in pairs)
506 width = len(str(len(pairs)))
507 hdr = (
508 f" {'#':>{width}} "
509 f"{'SYMBOL A':<{max_a}} ↔ {'SYMBOL B':<{max_b}} "
510 f"{'RATE':>5} {'CO-CHANGES':>10}"
511 )
512 print(hdr)
513 print(f" {'─' * (len(hdr) - 2)}")
514
515 for i, p in enumerate(pairs, 1):
516 rate_pct = f"{round(p['co_change_rate'] * 100)} %"
517 co_str = f"{p['co_changes']} / {p['commits_both_active']}"
518 badges: list[str] = []
519 if p["a_in_test"] or p["b_in_test"]:
520 badges.append("[test]")
521 if p["same_file"]:
522 badges.append("[same-file]")
523 badge_str = f" {' '.join(badges)}" if badges else ""
524 print(
525 f" {i:>{width}} "
526 f"{sanitize_display(p['symbol_a']):<{max_a}} ↔ {sanitize_display(p['symbol_b']):<{max_b}} "
527 f"{rate_pct:>5} {co_str:>10}{badge_str}"
528 )
529
530 print(
531 "\n⚠️ These symbol pairs change together but share no import link."
532 "\n Consider adding docs, a shared interface, or an explicit integration test."
533 )
534
535 # ── CLI ────────────────────────────────────────────────────────────────────────
536
537 def register(
538 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
539 ) -> None:
540 """Register the entangle subcommand."""
541 parser = subparsers.add_parser(
542 "entangle",
543 help=(
544 "Find symbol pairs that always change together but have no "
545 "structural import link — hidden coupling."
546 ),
547 description=__doc__,
548 formatter_class=argparse.RawDescriptionHelpFormatter,
549 )
550 parser.add_argument(
551 "--top", "-n",
552 type=int, default=_DEFAULT_TOP, metavar="N",
553 help=f"Number of pairs to show (default: {_DEFAULT_TOP}).",
554 )
555 parser.add_argument(
556 "--min-co-changes",
557 type=int, default=_DEFAULT_MIN_CO_CHANGES, metavar="N",
558 dest="min_co_changes",
559 help=(
560 f"Minimum number of commits where both symbols co-changed "
561 f"(default: {_DEFAULT_MIN_CO_CHANGES})."
562 ),
563 )
564 parser.add_argument(
565 "--min-rate",
566 type=float, default=_DEFAULT_MIN_RATE, metavar="RATE",
567 dest="min_rate",
568 help=(
569 "Minimum co-change rate 0.0–1.0: fraction of commits where "
570 "both symbols were active that they co-changed "
571 f"(default: {_DEFAULT_MIN_RATE}). "
572 "E.g. --min-rate 0.5 = show only pairs that co-changed >50 %% of the time."
573 ),
574 )
575 parser.add_argument(
576 "--symbol", "-s",
577 default=None, metavar="ADDRESS", dest="symbol_filter",
578 help=(
579 "Focus on a single symbol: show all pairs it forms with other "
580 "symbols, including structurally-linked ones. "
581 "Address format: file.py::SymbolName"
582 ),
583 )
584 parser.add_argument(
585 "--since",
586 default=None, metavar="REF",
587 help="Limit analysis to commits reachable from HEAD but not from REF.",
588 )
589 parser.add_argument(
590 "--max-commits",
591 type=int, default=_DEFAULT_MAX_COMMITS, metavar="N",
592 dest="max_commits",
593 help=f"Maximum commits to scan (default: {_DEFAULT_MAX_COMMITS}).",
594 )
595 parser.add_argument(
596 "--include-same-file",
597 action="store_true", dest="include_same_file",
598 help=(
599 "Include symbol pairs within the same file. By default, "
600 "same-file pairs are excluded since same-file co-change is expected."
601 ),
602 )
603 parser.add_argument(
604 "--granularity",
605 default="symbol", choices=("symbol", "directory"),
606 metavar="LEVEL", dest="granularity",
607 help="Aggregation level: 'symbol' (default) or 'directory'.",
608 )
609 parser.add_argument(
610 "--json", "-j",
611 action="store_true", dest="json_out",
612 help="Emit results as JSON.",
613 )
614 parser.set_defaults(func=run)
615
616 def run(args: argparse.Namespace) -> None:
617 """Find symbol pairs that always change together but share no import link.
618
619 Mines the commit history for symbol-level co-change patterns. Pairs
620 that co-change frequently but share no structural import link are
621 "entangled" — a hidden dependency that only shows up when one side is
622 changed and the other isn't. Co-change rate = co_changes / min(A_changes, B_changes).
623
624 Agent quickstart
625 ----------------
626 ::
627
628 muse code entangle --json
629 muse code entangle --top 20 --min-rate 0.8 --json
630 muse code entangle --symbol "src/billing.py::compute_total" --json
631 muse code entangle --since HEAD~50 --json
632
633 JSON fields
634 -----------
635 ref Commit ref scanned (HEAD).
636 commits_analysed Number of commits walked.
637 truncated ``true`` if ``--max-commits`` was reached.
638 filters Echo of filter arguments used.
639 pairs Ranked list of entangled pairs: ``symbol_a``,
640 ``symbol_b``, ``co_changes``, ``rate``.
641
642 Exit codes
643 ----------
644 0 Analysis complete.
645 1 Invalid arguments or ref not found.
646 2 Not inside a Muse repository.
647 """
648 elapsed = start_timer()
649 top: int = clamp_int(args.top, 1, 10_000, 'top')
650 min_co_changes: int = clamp_int(args.min_co_changes, 1, 100000, 'min_co_changes')
651 min_rate: float = args.min_rate
652 symbol_filter: str | None = args.symbol_filter
653 since: str | None = args.since
654 max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits')
655 include_same_file: bool = args.include_same_file
656 json_out: bool = args.json_out
657 granularity: str = getattr(args, "granularity", "symbol")
658
659 # ── Validation ────────────────────────────────────────────────────────────
660 if top < 1:
661 print("❌ --top must be >= 1.", file=sys.stderr)
662 raise SystemExit(ExitCode.USER_ERROR)
663 if min_co_changes < 1:
664 print("❌ --min-co-changes must be >= 1.", file=sys.stderr)
665 raise SystemExit(ExitCode.USER_ERROR)
666 if not (0.0 <= min_rate <= 1.0):
667 print("❌ --min-rate must be between 0.0 and 1.0.", file=sys.stderr)
668 raise SystemExit(ExitCode.USER_ERROR)
669 if max_commits < 1:
670 print("❌ --max-commits must be >= 1.", file=sys.stderr)
671 raise SystemExit(ExitCode.USER_ERROR)
672 if symbol_filter is not None and "::" not in symbol_filter:
673 print(
674 "❌ --symbol must be a qualified address (file.py::SymbolName).",
675 file=sys.stderr,
676 )
677 raise SystemExit(ExitCode.USER_ERROR)
678 if symbol_filter is not None and len(symbol_filter) > 500:
679 print("❌ --symbol address is too long (max 500 chars).", file=sys.stderr)
680 raise SystemExit(ExitCode.USER_ERROR)
681
682 # ── Repo setup ────────────────────────────────────────────────────────────
683 root = require_repo()
684 branch = read_current_branch(root)
685 ref = branch
686
687 filters: _JsonFilters = {
688 "min_rate": min_rate,
689 "min_co_changes": min_co_changes,
690 "symbol": symbol_filter,
691 "since": since,
692 "include_same_file": include_same_file,
693 "top": top,
694 "max_commits": max_commits,
695 }
696
697 head = resolve_commit_ref(root, branch, None)
698 if head is None:
699 # Empty repo — no commits yet; return empty result.
700 if json_out:
701 empty_out = dict(_EntangleOutputJson(
702 **make_envelope(elapsed),
703 ref=ref,
704 commits_analysed=0,
705 truncated=False,
706 filters=filters,
707 pairs=[],
708 ))
709 empty_out["granularity"] = granularity
710 print(json.dumps(empty_out))
711 else:
712 print(" (no entangled pairs found — repository has no commits)")
713 return
714
715 stop_at: str | None = None
716 if since is not None:
717 since_commit = resolve_commit_ref(root, branch, since)
718 if since_commit is None:
719 print(f"❌ Commit '{since}' not found.", file=sys.stderr)
720 raise SystemExit(ExitCode.USER_ERROR)
721 stop_at = since_commit.commit_id
722
723 # ── Directory granularity path ────────────────────────────────────────────
724 if granularity == "directory":
725 dir_co_changes, dir_active, commits_analysed, truncated = _collect_directory_pairs(
726 root,
727 head_commit_id=head.commit_id,
728 stop_at=stop_at,
729 max_commits=max_commits,
730 )
731 dir_pairs = _build_directory_pairs(
732 co_changes=dir_co_changes,
733 active=dir_active,
734 min_co_changes=min_co_changes,
735 min_rate=min_rate,
736 top=top,
737 )
738
739 if json_out:
740 out = dict(_EntangleOutputJson(
741 **make_envelope(elapsed),
742 ref=ref,
743 commits_analysed=commits_analysed,
744 truncated=truncated,
745 filters=filters,
746 pairs=[dict(p) for p in dir_pairs],
747 ))
748 out["granularity"] = "directory"
749 print(json.dumps(out))
750 else:
751 scope = f"{since}..{ref}" if since else ref
752 trunc = " ⚠️ truncated" if truncated else ""
753 print(
754 f"\nDirectory entanglement — {scope}"
755 f" ({commits_analysed} commits · {len(dir_pairs)} pair(s){trunc})"
756 )
757 if not dir_pairs:
758 print(" (no directory co-change pairs found)")
759 else:
760 for i, p in enumerate(dir_pairs, 1):
761 rate_pct = f"{round(p['co_change_rate'] * 100)} %"
762 print(f" {i:>3} {p['dir_a']:<50} ↔ {p['dir_b']:<50} {rate_pct:>5} {p['co_changes']}")
763 return
764
765 # ── Phase 1: build import map from HEAD snapshot ──────────────────────────
766 manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
767 import_map = _build_import_map(root, manifest)
768
769 # ── Phase 2: mine co-changes in one BFS pass ──────────────────────────────
770 co_changes, active, commits_analysed, truncated = _collect_symbol_pairs(
771 root,
772 head_commit_id=head.commit_id,
773 stop_at=stop_at,
774 max_commits=max_commits,
775 include_same_file=include_same_file,
776 )
777
778 # ── Phase 3: filter + rank ────────────────────────────────────────────────
779 pairs = _build_pairs(
780 co_changes=co_changes,
781 active=active,
782 import_map=import_map,
783 min_co_changes=min_co_changes,
784 min_rate=min_rate,
785 symbol_filter=symbol_filter,
786 top=top,
787 include_same_file=include_same_file,
788 )
789
790 # ── Output ────────────────────────────────────────────────────────────────
791 if json_out:
792 sym_out = dict(_EntangleOutputJson(
793 **make_envelope(elapsed),
794 ref=ref,
795 commits_analysed=commits_analysed,
796 truncated=truncated,
797 filters=filters,
798 pairs=[dict(p) for p in pairs],
799 ))
800 sym_out["granularity"] = "symbol"
801 print(json.dumps(sym_out))
802 return
803
804 _print_table(pairs, ref, commits_analysed, truncated, since)
File History 4 commits
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9 Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump… Human 10 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 68 days ago
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f chore: bump version to 0.2.0rc15 to match musehub#113 fix release Sonnet 4.6 patch 68 days ago
sha256:f3b726b50f0aee3622bba751e0a67aa7ae4cf75a798477dbce581940b6a9cf70 feat: migrate invariants cache to .muse/cache/invariants.ms… Sonnet 4.6 patch 135 days ago