languages.py
python
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