gabriel / muse public
languages.py python
420 lines 13.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 27 days ago
1 """muse code languages — language breakdown of the current snapshot.
2
3 Shows the composition of the repository by programming language —
4 how many files, symbols, and which symbol kinds are present for
5 each language.
6
7 By default import pseudo-symbols are excluded from counts so the
8 numbers reflect semantic code density (functions, classes, methods,
9 sections) rather than dependency volume. Pass ``--include-imports``
10 to add them back.
11
12 Pass ``--diff REF`` to see how language composition *changed* between
13 an earlier commit and the current snapshot (or ``--commit`` target).
14
15 Usage::
16
17 muse code languages
18 muse code languages --commit a3f2c9
19 muse code languages --diff a3f2c9
20 muse code languages --diff a3f2c9 --commit 97fe523d
21 muse code languages --sort symbols
22 muse code languages --json
23
24 Output::
25
26 Language breakdown — commit 97fe523d
27
28 Python 378 files 12 347 symbols (fn: 1974 class: 969 method: 3908 var: 797)
29 Markdown 45 files 2 239 symbols (section: 1502 var: 737)
30 TOML 1 file 56 symbols (var: 56)
31 Shell 2 files 0 symbols
32 ────────────────────────────────────────────────────────────────────────────
33 Total 437 files 14 642 symbols (6 languages)
34
35 Diff output (--diff a3f2c9)::
36
37 Language change — a3f2c9..97fe523d
38
39 Python +12 files +382 symbols (+3.2%)
40 Markdown +1 file +14 symbols (+0.6%)
41 TOML (unchanged)
42 ────────────────────────────────────────────────────────────────────────────
43 Net +13 files +396 symbols
44 """
45
46 from __future__ import annotations
47
48 import argparse
49 import json
50 import logging
51 import pathlib
52 import sys
53 from typing import TypedDict
54
55 from muse.core.errors import ExitCode
56 from muse.core.repo import read_repo_id, require_repo
57 from muse.core.store import (
58 Manifest,
59 get_commit_snapshot_manifest,
60 read_current_branch,
61 resolve_commit_ref,
62 )
63 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
64 from muse.plugins.code._query import language_of, symbols_for_snapshot
65
66 logger = logging.getLogger(__name__)
67
68 type LangCount = dict[str, int] # language → count (files or symbols)
69 type LangKinds = dict[str, dict[str, int]] # language → kind → count
70 _KindLabelMap = dict[str, str]
71
72 # Kinds treated as import pseudo-symbols — excluded from default counts.
73 _IMPORT_KINDS: frozenset[str] = frozenset({"import"})
74
75 # Display order and labels for known kinds.
76 _KIND_LABEL: _KindLabelMap = {
77 "function": "fn",
78 "async_function": "fn~",
79 "class": "class",
80 "method": "method",
81 "async_method": "method~",
82 "section": "section",
83 "variable": "var",
84 "import": "import",
85 }
86
87 _SORT_CHOICES = ("name", "files", "symbols")
88
89
90 class _LangEntry(TypedDict):
91 language: str
92 files: int
93 symbols: int
94 kinds: LangCount
95
96
97 class _DiffEntry(TypedDict):
98 language: str
99 delta_files: int
100 delta_symbols: int
101 files_before: int
102 files_after: int
103 symbols_before: int
104 symbols_after: int
105 status: str # "added" | "removed" | "changed" | "unchanged"
106
107
108
109 def _first_line(message: str) -> str:
110 for line in message.splitlines():
111 s = line.strip()
112 if s:
113 return s
114 return message.strip()
115
116
117 def _collect_stats(
118 root: pathlib.Path,
119 manifest: Manifest,
120 include_imports: bool,
121 cache: SymbolCache | None,
122 ) -> tuple[dict[str, int], dict[str, int], dict[str, dict[str, int]]]:
123 """Return (lang_files, lang_symbols, lang_kinds) for a manifest.
124
125 When *include_imports* is False, import pseudo-symbols are excluded from
126 symbol counts and kind breakdowns.
127 """
128 sc = cache
129 symbol_map = symbols_for_snapshot(root, manifest, cache=sc)
130
131 lang_files: LangCount = {}
132 lang_symbols: LangCount = {}
133 lang_kinds: LangKinds = {}
134
135 for file_path in manifest:
136 lang = language_of(file_path)
137 lang_files[lang] = lang_files.get(lang, 0) + 1
138
139 for file_path, tree in symbol_map.items():
140 lang = language_of(file_path)
141 kinds = lang_kinds.setdefault(lang, {})
142 for rec in tree.values():
143 kind: str = rec["kind"]
144 if not include_imports and kind in _IMPORT_KINDS:
145 continue
146 lang_symbols[lang] = lang_symbols.get(lang, 0) + 1
147 kinds[kind] = kinds.get(kind, 0) + 1
148
149 return lang_files, lang_symbols, lang_kinds
150
151
152 def _kind_str(kinds: LangCount) -> str:
153 """Format the kind breakdown as a parenthesised string."""
154 parts: list[str] = []
155 # Emit in canonical order for known kinds, then any remainder alphabetically.
156 seen: set[str] = set()
157 for k, label in _KIND_LABEL.items():
158 if k in kinds:
159 parts.append(f"{label}: {kinds[k]}")
160 seen.add(k)
161 for k in sorted(kinds):
162 if k not in seen:
163 parts.append(f"{k}: {kinds[k]}")
164 return f" ({', '.join(parts)})" if parts else ""
165
166
167 def _sorted_langs(
168 lang_files: LangCount,
169 lang_symbols: LangCount,
170 sort_by: str,
171 ) -> list[str]:
172 all_langs = list(lang_files)
173 if sort_by == "files":
174 all_langs.sort(key=lambda l: (-lang_files[l], l))
175 elif sort_by == "symbols":
176 all_langs.sort(key=lambda l: (-lang_symbols.get(l, 0), l))
177 else:
178 all_langs.sort()
179 return all_langs
180
181
182 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
183 """Register the languages subcommand."""
184 parser = subparsers.add_parser(
185 "languages",
186 help="Show the language composition of the repository.",
187 description=__doc__,
188 formatter_class=argparse.RawDescriptionHelpFormatter,
189 )
190 parser.add_argument(
191 "--commit", "-c",
192 dest="ref",
193 default=None,
194 metavar="REF",
195 help="Commit to inspect (default: HEAD).",
196 )
197 parser.add_argument(
198 "--diff", "-d",
199 dest="diff_ref",
200 default=None,
201 metavar="REF",
202 help="Show the language composition *change* from REF to --commit (or HEAD).",
203 )
204 parser.add_argument(
205 "--sort", "-s",
206 dest="sort_by",
207 default="name",
208 choices=_SORT_CHOICES,
209 metavar="KEY",
210 help=f"Sort output by: {', '.join(_SORT_CHOICES)} (default: name).",
211 )
212 parser.add_argument(
213 "--include-imports",
214 dest="include_imports",
215 action="store_true",
216 help="Include import pseudo-symbols in counts (excluded by default).",
217 )
218 parser.add_argument(
219 "--json",
220 dest="as_json",
221 action="store_true",
222 help="Emit results as JSON.",
223 )
224 parser.set_defaults(func=run)
225
226
227 def run(args: argparse.Namespace) -> None:
228 """Show the language composition of the repository.
229
230 Counts files and semantic symbols (functions, classes, methods, sections)
231 by programming language. Import pseudo-symbols are excluded by default
232 so the numbers reflect real code density.
233
234 Pass ``--diff REF`` to see how the breakdown *changed* between REF and the
235 target commit — perfect for sprint-over-sprint or release-over-release
236 language drift analysis.
237 """
238 ref: str | None = args.ref
239 diff_ref: str | None = args.diff_ref
240 sort_by: str = args.sort_by
241 include_imports: bool = args.include_imports
242 as_json: bool = args.as_json
243
244 root = require_repo()
245 repo_id = read_repo_id(root)
246 branch = read_current_branch(root)
247
248 commit_b = resolve_commit_ref(root, repo_id, branch, ref)
249 if commit_b is None:
250 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
251 raise SystemExit(ExitCode.USER_ERROR)
252
253 manifest_b: Manifest = get_commit_snapshot_manifest(root, commit_b.commit_id) or {}
254
255 # Shared cache across all snapshot loads.
256 shared_cache = load_symbol_cache(root)
257
258 files_b, syms_b, kinds_b = _collect_stats(root, manifest_b, include_imports, shared_cache)
259
260 # ── diff mode ────────────────────────────────────────────────────────────
261 if diff_ref is not None:
262 commit_a = resolve_commit_ref(root, repo_id, branch, diff_ref)
263 if commit_a is None:
264 print(f"❌ Commit '{diff_ref}' not found.", file=sys.stderr)
265 raise SystemExit(ExitCode.USER_ERROR)
266
267 manifest_a: Manifest = get_commit_snapshot_manifest(root, commit_a.commit_id) or {}
268 files_a, syms_a, _ = _collect_stats(root, manifest_a, include_imports, shared_cache)
269
270 all_langs = sorted(set(files_a) | set(files_b))
271
272 if as_json:
273 entries: list[_DiffEntry] = []
274 for lang in all_langs:
275 fa = files_a.get(lang, 0)
276 fb = files_b.get(lang, 0)
277 sa = syms_a.get(lang, 0)
278 sb = syms_b.get(lang, 0)
279 if fa == 0 and fb > 0:
280 status = "added"
281 elif fa > 0 and fb == 0:
282 status = "removed"
283 elif fa == fb and sa == sb:
284 status = "unchanged"
285 else:
286 status = "changed"
287 entries.append(_DiffEntry(
288 language=lang,
289 delta_files=fb - fa,
290 delta_symbols=sb - sa,
291 files_before=fa,
292 files_after=fb,
293 symbols_before=sa,
294 symbols_after=sb,
295 status=status,
296 ))
297 print(json.dumps({
298 "from": {"commit_id": commit_a.commit_id, "message": _first_line(commit_a.message)},
299 "to": {"commit_id": commit_b.commit_id, "message": _first_line(commit_b.message)},
300 "include_imports": include_imports,
301 "diff": entries,
302 }, indent=2))
303 return
304
305 _print_diff(
306 commit_a.commit_id, commit_b.commit_id,
307 files_a, syms_a, files_b, syms_b,
308 all_langs, sort_by,
309 )
310 return
311
312 # ── snapshot mode ────────────────────────────────────────────────────────
313 all_langs_snap = _sorted_langs(files_b, syms_b, sort_by)
314
315 if as_json:
316 out: list[_LangEntry] = [
317 _LangEntry(
318 language=lang,
319 files=files_b[lang],
320 symbols=syms_b.get(lang, 0),
321 kinds=kinds_b.get(lang, {}),
322 )
323 for lang in all_langs_snap
324 ]
325 print(json.dumps({
326 "commit": {
327 "commit_id": commit_b.commit_id,
328 "message": _first_line(commit_b.message),
329 },
330 "include_imports": include_imports,
331 "languages": out,
332 }, indent=2))
333 return
334
335 _print_snapshot(commit_b.commit_id, files_b, syms_b, kinds_b, all_langs_snap)
336
337
338 def _print_snapshot(
339 commit_id: str,
340 lang_files: LangCount,
341 lang_symbols: LangCount,
342 lang_kinds: LangKinds,
343 langs: list[str],
344 ) -> None:
345 print(f"\nLanguage breakdown — commit {commit_id[:8]}\n")
346 max_lang = max((len(l) for l in langs), default=8)
347 total_files = total_syms = 0
348 for lang in langs:
349 files = lang_files[lang]
350 syms = lang_symbols.get(lang, 0)
351 total_files += files
352 total_syms += syms
353 kinds = lang_kinds.get(lang, {})
354 ks = _kind_str(kinds)
355 file_label = "file " if files == 1 else "files"
356 print(f" {lang:<{max_lang}} {files:>4} {file_label} {syms:>6} symbols{ks}")
357 print(" " + "─" * 66)
358 print(
359 f" {'Total':<{max_lang}} {total_files:>4} files {total_syms:>6} symbols"
360 f" ({len(langs)} languages)"
361 )
362
363
364 def _print_diff(
365 commit_id_a: str,
366 commit_id_b: str,
367 files_a: LangCount,
368 syms_a: LangCount,
369 files_b: LangCount,
370 syms_b: LangCount,
371 all_langs: list[str],
372 sort_by: str,
373 ) -> None:
374 print(f"\nLanguage change — {commit_id_a[:8]}..{commit_id_b[:8]}\n")
375 max_lang = max((len(l) for l in all_langs), default=8)
376 net_files = net_syms = 0
377
378 # Sort diff: by abs(delta_symbols) desc, then name.
379 def _sort_key(lang: str) -> tuple[int, int, str]:
380 sa = syms_a.get(lang, 0)
381 sb = syms_b.get(lang, 0)
382 if sort_by == "symbols":
383 return (0, -(abs(sb - sa)), lang)
384 if sort_by == "files":
385 fa = files_a.get(lang, 0)
386 fb = files_b.get(lang, 0)
387 return (0, -(abs(fb - fa)), lang)
388 return (0, 0, lang)
389
390 sorted_langs = sorted(all_langs, key=_sort_key)
391
392 for lang in sorted_langs:
393 fa = files_a.get(lang, 0)
394 fb = files_b.get(lang, 0)
395 sa = syms_a.get(lang, 0)
396 sb = syms_b.get(lang, 0)
397 df = fb - fa
398 ds = sb - sa
399 net_files += df
400 net_syms += ds
401
402 if df == 0 and ds == 0:
403 print(f" {lang:<{max_lang}} (unchanged)")
404 continue
405
406 status = ""
407 if fa == 0:
408 status = " (new)"
409 elif fb == 0:
410 status = " (removed)"
411
412 df_str = f"{df:+d} {'file' if abs(df) == 1 else 'files'}"
413 pct = f" ({ds / sa * 100:+.1f}%)" if sa > 0 else (" (+∞)" if ds > 0 else "")
414 ds_str = f"{ds:+d} symbols{pct}"
415 print(f" {lang:<{max_lang}} {df_str:<14} {ds_str}{status}")
416
417 print(" " + "─" * 66)
418 ndf_str = f"{net_files:+d} {'file' if abs(net_files) == 1 else 'files'}"
419 nds_str = f"{net_syms:+d} symbols"
420 print(f" {'Net':<{max_lang}} {ndf_str:<14} {nds_str}")
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 27 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 27 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 30 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 49 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago