hotspots.py python
346 lines 12.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
1 """muse code hotspots -- symbol churn leaderboard.
2
3 Walks the commit history and counts how many commits touched each symbol.
4 High churn = instability signal. The functions that change most are the
5 ones that need the most attention -- refactoring targets, test coverage gaps,
6 or domain logic under active evolution.
7
8 Unlike file-level churn metrics, ``muse code hotspots`` operates at the
9 *symbol* level: a 5,000-line module with one unstable function shows that
10 function at the top, not the whole file.
11
12 Import pseudo-symbols (``::import::*``) are excluded by default because they
13 almost always reflect dependency management rather than logic churn. Pass
14 ``--include-imports`` to include them.
15
16 Usage::
17
18 muse code hotspots
19 muse code hotspots --top 20
20 muse code hotspots --kind function --language Python
21 muse code hotspots --from HEAD~30 --to HEAD
22 muse code hotspots --min 3 # only symbols that changed >= 3 times
23 muse code hotspots --json # machine-readable for agents
24
25 Output::
26
27 Symbol churn -- top 10 most-changed symbols
28 Commits analysed: 47
29
30 1 src/billing.py::compute_invoice_total 12 changes
31 2 src/api.py::handle_request 9 changes
32 3 src/auth.py::validate_token 7 changes
33 4 src/models.py::User.save 5 changes
34
35 High churn = instability signal.
36 """
37
38 from __future__ import annotations
39
40 import argparse
41 import json
42 import logging
43 import pathlib
44 import sys
45
46 from muse.core.errors import ExitCode
47 from muse.core.repo import read_repo_id, require_repo
48 from muse.core.store import read_current_branch, resolve_commit_ref
49 from muse.domain import DomainOp
50 from muse.plugins.code._query import (
51 flat_symbol_ops,
52 language_of,
53 walk_commits_bfs,
54 )
55
56 logger = logging.getLogger(__name__)
57
58 _DEFAULT_TOP = 20
59 _DEFAULT_MAX_COMMITS = 10_000
60
61 # Canonical kind names produced by Muse's AST parser summaries.
62 _KNOWN_KINDS: frozenset[str] = frozenset({
63 "function", "async_function", "class", "method", "async_method",
64 "variable", "import", "section", "rule",
65 })
66
67 # Language normalisation (case-insensitive --language matching).
68 from muse.plugins.code._query import _SUFFIX_LANG
69 from muse.core.validation import clamp_int, sanitize_display
70
71
72 type _IntMap = dict[str, int]
73 type _StrMap = dict[str, str]
74 _LANG_CANONICAL: _StrMap = {lang.lower(): lang for lang in set(_SUFFIX_LANG.values())}
75
76
77 def _normalise_language(lang: str) -> str:
78 return _LANG_CANONICAL.get(lang.strip().lower(), lang.strip())
79
80
81 def _kind_from_op(op: DomainOp) -> str:
82 """Extract the symbol kind from the op's summary fields.
83
84 ``replace`` ops carry the kind in ``old_summary`` as the first word::
85
86 "function _collect_paths (implementation)" → "function"
87
88 ``insert`` / ``delete`` ops carry it in ``content_summary`` as the
89 second word (after "added" / "removed")::
90
91 "added function test_fn L10–20" → "function"
92 "removed import json L5–5" → "import"
93 """
94 if op["op"] == "replace":
95 raw = op.get("old_summary")
96 summary: str = raw if isinstance(raw, str) else ""
97 parts = summary.split(None, 1)
98 if parts and parts[0] in _KNOWN_KINDS:
99 return parts[0]
100 else:
101 raw2 = op.get("content_summary")
102 summary2: str = raw2 if isinstance(raw2, str) else ""
103 parts2 = summary2.split()
104 if len(parts2) >= 2 and parts2[1] in _KNOWN_KINDS:
105 return parts2[1]
106 return ""
107
108
109 # ---------------------------------------------------------------------------
110 # Repository helpers
111 # ---------------------------------------------------------------------------
112
113
114
115 # ---------------------------------------------------------------------------
116 # Churn collection
117 # ---------------------------------------------------------------------------
118
119
120 def _collect_churn(
121 root: pathlib.Path,
122 to_commit_id: str,
123 from_commit_id: str | None,
124 kind_filter: str | None,
125 language_filter: str | None,
126 include_imports: bool,
127 max_commits: int,
128 ) -> tuple[dict[str, int], int, bool]:
129 """Return ``(churn_counts, commits_analysed, truncated)``.
130
131 Uses a BFS walk that follows both ``parent_commit_id`` and
132 ``parent2_commit_id``, so events on merged feature branches are included.
133 """
134 commits, truncated = walk_commits_bfs(
135 root, to_commit_id, max_commits, stop_at_commit_id=from_commit_id
136 )
137 counts: _IntMap = {}
138 for commit in commits:
139 if commit.structured_delta is None:
140 continue
141 for op in flat_symbol_ops(commit.structured_delta["ops"]):
142 addr: str = op["address"]
143
144 # Exclude import pseudo-symbols unless requested.
145 if not include_imports and "::import::" in addr:
146 continue
147
148 file_path = addr.split("::")[0]
149 if language_filter and language_of(file_path) != language_filter:
150 continue
151
152 if kind_filter:
153 if _kind_from_op(op) != kind_filter:
154 continue
155
156 counts[addr] = counts.get(addr, 0) + 1
157
158 return counts, len(commits), truncated
159
160
161 # ---------------------------------------------------------------------------
162 # Argument parser registration
163 # ---------------------------------------------------------------------------
164
165
166 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
167 """Register the hotspots subcommand."""
168 parser = subparsers.add_parser(
169 "hotspots",
170 help="Show the symbols that change most often — the churn leaderboard.",
171 description=__doc__,
172 formatter_class=argparse.RawDescriptionHelpFormatter,
173 )
174 parser.add_argument(
175 "--top", "-n", type=int, default=_DEFAULT_TOP, metavar="N", dest="top",
176 help=f"Number of symbols to show (default: {_DEFAULT_TOP}).",
177 )
178 parser.add_argument(
179 "--min", type=int, default=1, metavar="N", dest="min_changes",
180 help="Only show symbols that changed at least N times (default: 1).",
181 )
182 parser.add_argument(
183 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
184 help="Restrict to symbols of this kind (function, class, method, …).",
185 )
186 parser.add_argument(
187 "--language", "-l", default=None, metavar="LANG", dest="language_filter",
188 help="Restrict to symbols from files of this language (case-insensitive).",
189 )
190 parser.add_argument(
191 "--include-imports", action="store_true", dest="include_imports",
192 help="Include import pseudo-symbols (excluded by default).",
193 )
194 parser.add_argument(
195 "--from", default=None, metavar="REF", dest="from_ref",
196 help="Exclusive start of the commit range (default: initial commit).",
197 )
198 parser.add_argument(
199 "--to", default=None, metavar="REF", dest="to_ref",
200 help="Inclusive end of the commit range (default: HEAD).",
201 )
202 parser.add_argument(
203 "--max-commits", type=int, default=_DEFAULT_MAX_COMMITS, metavar="N",
204 dest="max_commits",
205 help=f"Maximum commits to scan (default: {_DEFAULT_MAX_COMMITS}).",
206 )
207 parser.add_argument(
208 "--json", action="store_true", dest="as_json",
209 help="Emit results as structured JSON.",
210 )
211 parser.set_defaults(func=run)
212
213
214 # ---------------------------------------------------------------------------
215 # Command entry point
216 # ---------------------------------------------------------------------------
217
218
219 def run(args: argparse.Namespace) -> None:
220 """Show the symbols that change most often — the churn leaderboard.
221
222 Walks the commit history (BFS, follows both parents of merge commits) and
223 counts how many commits touched each symbol. High churn at the function
224 level reveals instability that file-level metrics miss: a single file may
225 be stable while one specific function inside it burns.
226
227 Import pseudo-symbols (``::import::*``) are excluded by default. Use
228 ``--include-imports`` to include them.
229
230 Use ``--from`` / ``--to`` to scope the analysis to a sprint or release.
231 Use ``--kind function`` to focus on functions only.
232 Use ``--min 3`` to surface only the truly hot symbols.
233 """
234 top: int = clamp_int(args.top, 1, 10_000, 'top')
235 min_changes: int = clamp_int(args.min_changes, 0, 100000, 'min_changes')
236 kind_filter: str | None = args.kind_filter
237 language_filter: str | None = args.language_filter
238 include_imports: bool = args.include_imports
239 from_ref: str | None = args.from_ref
240 to_ref: str | None = args.to_ref
241 max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits')
242 as_json: bool = args.as_json
243
244 # ── Validation ────────────────────────────────────────────────────────────
245
246 if top < 1:
247 print("❌ --top must be at least 1.", file=sys.stderr)
248 raise SystemExit(ExitCode.USER_ERROR)
249
250 if min_changes < 1:
251 print("❌ --min must be at least 1.", file=sys.stderr)
252 raise SystemExit(ExitCode.USER_ERROR)
253
254 if max_commits < 1:
255 print("❌ --max-commits must be at least 1.", file=sys.stderr)
256 raise SystemExit(ExitCode.USER_ERROR)
257
258 if language_filter is not None:
259 language_filter = _normalise_language(language_filter)
260
261 # ── Repo / commit resolution ──────────────────────────────────────────────
262
263 root = require_repo()
264
265 # Knowtation domain: delegate to the vault-aware note-churn implementation.
266 from muse.plugins.registry import read_domain
267 try:
268 if read_domain(root) == "knowtation":
269 from muse.plugins.knowtation.cli_hooks import run_hotspots_for_vault
270 run_hotspots_for_vault(root, args)
271 return
272 except Exception: # noqa: BLE001 — fall through to Code domain on any failure
273 pass
274
275 repo_id = read_repo_id(root)
276 branch = read_current_branch(root)
277
278 to_commit = resolve_commit_ref(root, repo_id, branch, to_ref)
279 if to_commit is None:
280 print(f"❌ Commit '{to_ref or 'HEAD'}' not found.", file=sys.stderr)
281 raise SystemExit(ExitCode.USER_ERROR)
282
283 from_commit_id: str | None = None
284 if from_ref is not None:
285 from_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
286 if from_commit is None:
287 print(f"❌ Commit '{from_ref}' not found.", file=sys.stderr)
288 raise SystemExit(ExitCode.USER_ERROR)
289 from_commit_id = from_commit.commit_id
290
291 # ── Churn analysis ────────────────────────────────────────────────────────
292
293 counts, total_commits, truncated = _collect_churn(
294 root, to_commit.commit_id, from_commit_id,
295 kind_filter, language_filter, include_imports, max_commits,
296 )
297
298 # Apply --min filter before ranking.
299 if min_changes > 1:
300 counts = {addr: n for addr, n in counts.items() if n >= min_changes}
301
302 ranked = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:top]
303
304 # ── Output ────────────────────────────────────────────────────────────────
305
306 if as_json:
307 print(json.dumps({
308 "from_ref": from_ref,
309 "to_ref": to_ref or branch,
310 "commits_analysed": total_commits,
311 "truncated": truncated,
312 "filters": {
313 "kind": kind_filter,
314 "language": language_filter,
315 "include_imports": include_imports,
316 "min_changes": min_changes,
317 },
318 "hotspots": [{"address": a, "changes": c} for a, c in ranked],
319 }, indent=2))
320 return
321
322 if not ranked:
323 print(" (no symbol-level changes found in this range)")
324 return
325
326 filters_desc = ""
327 if kind_filter:
328 filters_desc += f" kind={kind_filter}"
329 if language_filter:
330 filters_desc += f" language={language_filter}"
331 if min_changes > 1:
332 filters_desc += f" min={min_changes}"
333
334 print(f"\nSymbol churn — top {len(ranked)} most-changed symbols{filters_desc}")
335 print(f"Commits analysed: {total_commits}", end="")
336 if truncated:
337 print(f" ⚠️ (capped at --max-commits {max_commits})", end="")
338 print("\n")
339
340 width = len(str(len(ranked)))
341 for rank, (addr, count) in enumerate(ranked, 1):
342 label = "change" if count == 1 else "changes"
343 print(f" {rank:>{width}} {sanitize_display(addr):<60} {count:>4} {label}")
344
345 print("")
346 print("High churn = instability signal. Consider refactoring or adding tests.")
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago