clones.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """muse code clones β€” find duplicate and near-duplicate symbols.
2
3 Detects two tiers of code duplication from committed snapshot data:
4
5 **Exact clones**
6 Symbols with the same ``body_hash`` at different addresses. The body is
7 character-for-character identical (after normalisation) even if the name or
8 surrounding context differs. These are true copy-paste duplicates.
9
10 **Near-clones**
11 Symbols with the same ``signature_id`` but different ``body_hash``. Same
12 function signature, different implementation β€” strong candidates for
13 consolidation behind a shared abstraction.
14
15 Git has no concept of these. Git stores file diffs; Muse stores symbol
16 identity hashes. Clone detection is a single pass over the snapshot index.
17
18 Usage::
19
20 muse code clones
21 muse code clones --tier exact
22 muse code clones --tier near
23 muse code clones --kind function
24 muse code clones --language Python
25 muse code clones --file muse/core/
26 muse code clones --exclude-same-file
27 muse code clones --commit HEAD~10
28 muse code clones --min-cluster 3
29 muse code clones --json
30
31 Output::
32
33 Clone analysis β€” commit a1b2c3d4
34
35 Exact clones (2 clusters):
36 body_hash a1b2c3d4:
37 src/billing.py::compute_hash function
38 src/utils.py::compute_hash function
39 src/legacy.py::_hash function
40
41 Near-clones β€” same signature (3 clusters):
42 signature_id e5f6a7b8:
43 src/billing.py::validate function
44 src/auth.py::validate function
45
46 Files with most clone members:
47 src/billing.py 4 clone symbols
48
49 Flags:
50
51 ``--tier {exact|near|both}``
52 Which tier to report (default: both).
53
54 ``--kind KIND``
55 Restrict to symbols of this kind (function, class, method, …).
56
57 ``--language LANG``
58 Restrict to files of this language.
59
60 ``--file PATH``
61 Restrict to symbols whose file path starts with PATH.
62
63 ``--exclude-same-file``
64 Skip clusters where every member lives in the same file.
65
66 ``--min-cluster N``
67 Only show clusters with at least N members (default: 2).
68
69 ``--commit, -c REF``
70 Analyse a historical snapshot instead of HEAD.
71
72 ``--json``
73 Emit results as JSON.
74 """
75
76 from __future__ import annotations
77
78 import argparse
79 import json
80 import logging
81 import pathlib
82 from typing import Literal, TypedDict
83
84 from muse._version import __version__
85 from muse.core.errors import ExitCode
86 from muse.core.repo import read_repo_id, require_repo
87 from muse.core._types import Manifest
88 from muse.core.store import get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
89 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
90 from muse.plugins.code._query import language_of, symbols_for_snapshot
91 from muse.plugins.code.ast_parser import SymbolRecord
92 from muse.core.validation import clamp_int, sanitize_display
93
94 type _SymIndex = dict[str, list[tuple[str, SymbolRecord]]]
95 type _FileCountMap = dict[str, int]
96
97 logger = logging.getLogger(__name__)
98
99 CloneTier = Literal["exact", "near", "both"]
100
101
102 # ---------------------------------------------------------------------------
103 # Typed output shapes
104 # ---------------------------------------------------------------------------
105
106
107 class _MemberDict(TypedDict):
108 address: str
109 kind: str
110 language: str
111 body_hash: str
112 signature_id: str
113 content_id: str
114
115
116 class _ClusterDict(TypedDict):
117 tier: str
118 hash: str
119 count: int
120 members: list[_MemberDict]
121
122
123 class _FileHotspot(TypedDict):
124 file: str
125 clone_symbols: int
126
127
128 # ---------------------------------------------------------------------------
129 # Core data model
130 # ---------------------------------------------------------------------------
131
132
133 class _CloneCluster:
134 """A group of symbols that are duplicates of each other."""
135
136 def __init__(
137 self,
138 tier: CloneTier,
139 hash_value: str,
140 members: list[tuple[str, SymbolRecord]],
141 ) -> None:
142 self.tier = tier
143 self.hash_value = hash_value
144 self.members = members # (address, record)
145
146 def to_dict(self) -> _ClusterDict:
147 return {
148 "tier": self.tier,
149 "hash": self.hash_value[:8],
150 "count": len(self.members), # int β€” not str
151 "members": [
152 _MemberDict(
153 address=addr,
154 kind=rec["kind"],
155 language=language_of(addr.split("::")[0]),
156 body_hash=rec["body_hash"][:8],
157 signature_id=rec["signature_id"][:8],
158 content_id=rec["content_id"][:8],
159 )
160 for addr, rec in self.members
161 ],
162 }
163
164
165 # ---------------------------------------------------------------------------
166 # Detection logic
167 # ---------------------------------------------------------------------------
168
169
170 def find_clones(
171 root: pathlib.Path,
172 manifest: Manifest,
173 tier: CloneTier,
174 kind_filter: str | None,
175 min_cluster: int,
176 *,
177 language_filter: str | None = None,
178 file_filter: str | None = None,
179 exclude_same_file: bool = False,
180 cache: SymbolCache | None = None,
181 ) -> list[_CloneCluster]:
182 """Build clone clusters from *manifest*.
183
184 Args:
185 root: Repository root (object store location).
186 manifest: Snapshot manifest: file path β†’ SHA-256 object ID.
187 tier: Which clone tier(s) to detect: "exact", "near", "both".
188 kind_filter: If set, only analyse symbols of this kind.
189 min_cluster: Minimum cluster size to report (default 2).
190 language_filter: If set, only analyse files of this language.
191 file_filter: If set, only analyse symbols whose file path starts
192 with this prefix (e.g. "muse/core/").
193 exclude_same_file: If True, skip clusters where all members are in
194 the same file (eliminates test-helper noise).
195 cache: Optional shared ``SymbolCache``. Pass one to avoid
196 re-parsing blobs when the caller has a warm cache.
197 """
198 sym_map = symbols_for_snapshot(
199 root,
200 manifest,
201 kind_filter=kind_filter,
202 language_filter=language_filter,
203 cache=cache,
204 )
205
206 # Flatten to list of (address, record), applying file_filter.
207 all_syms: list[tuple[str, SymbolRecord]] = [
208 (addr, rec)
209 for fp, tree in sorted(sym_map.items())
210 if file_filter is None or fp.startswith(file_filter)
211 for addr, rec in sorted(tree.items())
212 if rec["kind"] != "import"
213 ]
214
215 clusters: list[_CloneCluster] = []
216
217 if tier in ("exact", "both"):
218 body_index: _SymIndex = {}
219 for addr, rec in all_syms:
220 body_index.setdefault(rec["body_hash"], []).append((addr, rec))
221 for body_hash, members in sorted(body_index.items()):
222 if len(members) < min_cluster:
223 continue
224 if exclude_same_file and _all_same_file(members):
225 continue
226 clusters.append(_CloneCluster("exact", body_hash, members))
227
228 if tier in ("near", "both"):
229 sig_index: _SymIndex = {}
230 for addr, rec in all_syms:
231 sig_index.setdefault(rec["signature_id"], []).append((addr, rec))
232 for sig_id, members in sorted(sig_index.items()):
233 # Near-clone: same signature, at least two DIFFERENT body hashes.
234 unique_bodies = {r["body_hash"] for _, r in members}
235 if len(members) < min_cluster or len(unique_bodies) <= 1:
236 continue
237 if exclude_same_file and _all_same_file(members):
238 continue
239 clusters.append(_CloneCluster("near", sig_id, members))
240
241 # Sort: largest clusters first, then by tier (exact before near), then hash.
242 clusters.sort(key=lambda c: (-len(c.members), c.tier, c.hash_value))
243 return clusters
244
245
246 def _all_same_file(members: list[tuple[str, SymbolRecord]]) -> bool:
247 """Return True when every member lives in the same source file."""
248 files = {addr.split("::")[0] for addr, _ in members}
249 return len(files) == 1
250
251
252 def _file_hotspots(
253 clusters: list[_CloneCluster],
254 top: int = 10,
255 ) -> list[_FileHotspot]:
256 """Rank files by the number of clone-member symbols they contain."""
257 file_counts: _FileCountMap = {}
258 for cluster in clusters:
259 for addr, _ in cluster.members:
260 fp = addr.split("::")[0]
261 file_counts[fp] = file_counts.get(fp, 0) + 1
262 ranked = sorted(file_counts.items(), key=lambda t: t[1], reverse=True)[:top]
263 return [_FileHotspot(file=fp, clone_symbols=cnt) for fp, cnt in ranked]
264
265
266 # ---------------------------------------------------------------------------
267 # CLI registration and entry point
268 # ---------------------------------------------------------------------------
269
270
271 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
272 """Register the clones subcommand."""
273 parser = subparsers.add_parser(
274 "clones",
275 help="Find exact and near-duplicate symbols in the committed snapshot.",
276 description=__doc__,
277 formatter_class=argparse.RawDescriptionHelpFormatter,
278 )
279 parser.add_argument(
280 "--tier", "-t", default="both", choices=["exact", "near", "both"],
281 help="Tier to report: exact, near, or both (default: both).",
282 )
283 parser.add_argument(
284 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
285 help="Restrict to symbols of this kind (function, class, method, …).",
286 )
287 parser.add_argument(
288 "--language", "-l", default=None, metavar="LANG", dest="language_filter",
289 help="Restrict to files of this language.",
290 )
291 parser.add_argument(
292 "--file", "-f", default=None, metavar="PATH", dest="file_filter",
293 help="Restrict to symbols whose file path starts with PATH.",
294 )
295 parser.add_argument(
296 "--exclude-same-file", action="store_true", dest="exclude_same_file",
297 help="Skip clusters where all members are in the same file.",
298 )
299 parser.add_argument(
300 "--min-cluster", "-m", type=int, default=2, metavar="N", dest="min_cluster",
301 help="Only show clusters with at least N members (default: 2).",
302 )
303 parser.add_argument(
304 "--commit", "-c", default=None, metavar="REF", dest="ref",
305 help="Analyse this commit instead of HEAD.",
306 )
307 parser.add_argument(
308 "--json", action="store_true", dest="as_json",
309 help="Emit results as JSON.",
310 )
311 parser.set_defaults(func=run)
312
313
314 def run(args: argparse.Namespace) -> None:
315 """Find exact and near-duplicate symbols in the committed snapshot.
316
317 Exact clones share the same ``body_hash`` (identical implementation).
318 Near-clones share the same ``signature_id`` but differ in body β€” same
319 contract, different implementation. Both are candidates for consolidation
320 behind shared abstractions.
321
322 Uses content-addressed hashes from the snapshot index β€” no AST
323 recomputation at query time. A shared SymbolCache ensures each blob is
324 parsed at most once across all analysis passes.
325 """
326 tier: CloneTier = args.tier
327 kind_filter: str | None = args.kind_filter
328 language_filter: str | None = args.language_filter
329 file_filter: str | None = args.file_filter
330 exclude_same_file: bool = args.exclude_same_file
331 min_cluster: int = clamp_int(args.min_cluster, 1, 10000, 'min_cluster')
332 ref: str | None = args.ref
333 as_json: bool = args.as_json
334
335 if min_cluster < 2:
336 logger.error("--min-cluster must be at least 2, got %d", min_cluster)
337 raise SystemExit(ExitCode.USER_ERROR)
338
339 root = require_repo()
340
341 from muse.plugins.registry import read_domain
342 try:
343 if read_domain(root) == "knowtation":
344 from muse.plugins.knowtation.cli_hooks import run_clones_for_vault
345 run_clones_for_vault(root, args)
346 return
347 except Exception: # noqa: BLE001
348 pass
349
350 try:
351 repo_id = read_repo_id(root)
352 except (FileNotFoundError, json.JSONDecodeError, KeyError) as exc:
353 logger.error("Cannot read repo identity: %s", exc)
354 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
355
356 branch = read_current_branch(root)
357 commit = resolve_commit_ref(root, repo_id, branch, ref)
358 if commit is None:
359 logger.error("Commit %r not found.", ref or "HEAD")
360 raise SystemExit(ExitCode.USER_ERROR)
361
362 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
363
364 # Shared cache β€” each blob parsed at most once.
365 cache = load_symbol_cache(root)
366
367 cluster_list = find_clones(
368 root,
369 manifest,
370 tier,
371 kind_filter,
372 min_cluster,
373 language_filter=language_filter,
374 file_filter=file_filter,
375 exclude_same_file=exclude_same_file,
376 cache=cache,
377 )
378
379 exact_clusters = [c for c in cluster_list if c.tier == "exact"]
380 near_clusters = [c for c in cluster_list if c.tier == "near"]
381 total_symbols = sum(len(c.members) for c in cluster_list)
382 hotspots = _file_hotspots(cluster_list)
383
384 if as_json:
385 print(json.dumps(
386 {
387 "schema_version": __version__,
388 "commit": commit.commit_id[:8],
389 "branch": branch,
390 "tier": tier,
391 "min_cluster": min_cluster,
392 "kind_filter": kind_filter,
393 "language_filter": language_filter,
394 "file_filter": file_filter,
395 "exclude_same_file": exclude_same_file,
396 "exact_clone_clusters": len(exact_clusters),
397 "near_clone_clusters": len(near_clusters),
398 "total_symbols_involved": total_symbols,
399 "file_hotspots": hotspots,
400 "clusters": [c.to_dict() for c in cluster_list],
401 },
402 indent=2,
403 ))
404 return
405
406 print(f"\nClone analysis β€” commit {commit.commit_id[:8]}")
407 if kind_filter:
408 print(f" (kind: {kind_filter})")
409 if language_filter:
410 print(f" (language: {language_filter})")
411 if file_filter:
412 print(f" (file prefix: {file_filter})")
413 if exclude_same_file:
414 print(" (same-file clusters excluded)")
415 print("─" * 62)
416
417 if not cluster_list:
418 print("\n βœ… No clones detected.")
419 return
420
421 if exact_clusters and tier in ("exact", "both"):
422 print(f"\nExact clones ({len(exact_clusters)} cluster(s)):")
423 for cl in exact_clusters:
424 print(f" body_hash {cl.hash_value[:8]}:")
425 for addr, rec in cl.members:
426 print(f" {sanitize_display(addr)} {rec['kind']}")
427
428 if near_clusters and tier in ("near", "both"):
429 print(f"\nNear-clones β€” same signature ({len(near_clusters)} cluster(s)):")
430 for cl in near_clusters:
431 print(f" signature_id {cl.hash_value[:8]}:")
432 for addr, rec in cl.members:
433 print(f" {sanitize_display(addr)} {rec['kind']} (body {rec['body_hash'][:8]})")
434
435 print(f"\n {len(cluster_list)} clone cluster(s), {total_symbols} total symbol(s) involved")
436
437 if hotspots:
438 print(f"\nFiles with most clone members:")
439 for h in hotspots[:5]:
440 print(f" {sanitize_display(h['file'])} {h['clone_symbols']} clone symbol(s)")