gabriel / muse public
release_analysis.py python
411 lines 14.6 KB
Raw
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d fix: migration must never update identity.toml on a failed … Sonnet 5 minor ⚠ breaking 9 hours ago
1 """Semantic release analysis for the code domain.
2
3 Computes a :class:`~muse.core.store.SemanticReleaseReport` by interrogating
4 the content-addressed object store and the commit graph. Called at
5 ``muse release push`` time so that MuseHub receives a pre-computed report
6 alongside the release payload — the server never needs to run analysis itself.
7
8 Architecture note
9 -----------------
10 This module intentionally lives in ``muse.plugins.code`` (not ``muse.core``)
11 because the analysis is code-domain-specific: it depends on AST parsing and
12 language classification provided by the code plugin. ``muse.core`` remains
13 domain-agnostic; only the resulting :class:`SemanticReleaseReport` TypedDict
14 is stored there as a plain data container.
15 """
16
17 import logging
18 import pathlib
19
20 from muse.core.semver import (
21 ApiChangeSummary,
22 ChangelogEntry,
23 FileHotspot,
24 LanguageStat,
25 RefactorEventSummary,
26 SemanticReleaseReport,
27 SymbolKindCount,
28 )
29 from muse.core.types import Manifest
30 from muse.core.commits import walk_commits_between
31 from muse.core.snapshots import read_snapshot
32 from muse.core.releases import ReleaseRecord
33
34 type SymbolTreeIndex = dict[str, "SymbolTree"]
35 type ApiSurface = dict[str, tuple[str, "SymbolRecord"]]
36 type CounterMap = dict[str, int]
37 from muse.domain import DomainOp
38 from muse.plugins.code._query import (
39 flat_symbol_ops,
40 is_semantic,
41 language_of,
42 symbols_for_snapshot,
43 touched_files,
44 )
45 from muse.plugins.code.ast_parser import SymbolRecord, SymbolTree
46
47 logger = logging.getLogger(__name__)
48
49 # Safety cap — skip symbol extraction on very large snapshots to keep push fast.
50 _MAX_SEMANTIC_FILES = 800
51
52 # ---------------------------------------------------------------------------
53 # Internal helpers
54 # ---------------------------------------------------------------------------
55
56 def _empty_report() -> SemanticReleaseReport:
57 return SemanticReleaseReport(
58 languages=[],
59 total_files=0,
60 semantic_files=0,
61 total_symbols=0,
62 symbols_by_kind=[],
63 files_changed=0,
64 api_added=[],
65 api_removed=[],
66 api_modified=[],
67 file_hotspots=[],
68 refactor_events=[],
69 breaking_changes=[],
70 human_commits=0,
71 agent_commits=0,
72 unique_agents=[],
73 unique_models=[],
74 reviewers=[],
75 )
76
77 def _is_public_symbol(name: str, kind: str) -> bool:
78 """Return True for symbols that are part of a public API surface.
79
80 Excludes dunder methods (except ``__init__`` and ``__call__``), private
81 names (single underscore prefix), and import/section symbols which are
82 structural rather than callable API.
83 """
84 if kind in ("import", "section", "rule"):
85 return False
86 if name.startswith("__") and name.endswith("__"):
87 return name in ("__init__", "__call__", "__new__")
88 return not name.startswith("_")
89
90 def _build_language_stats(
91 manifest: Manifest,
92 symbol_map: SymbolTreeIndex,
93 ) -> list[LanguageStat]:
94 """Aggregate per-language file and symbol counts from *manifest*."""
95 lang_files: CounterMap = {}
96 lang_symbols: CounterMap = {}
97
98 for file_path in manifest:
99 lang = language_of(file_path)
100 lang_files[lang] = lang_files.get(lang, 0) + 1
101
102 for file_path, tree in symbol_map.items():
103 lang = language_of(file_path)
104 lang_symbols[lang] = lang_symbols.get(lang, 0) + len(tree)
105
106 stats: list[LanguageStat] = [
107 LanguageStat(
108 language=lang,
109 files=lang_files[lang],
110 symbols=lang_symbols.get(lang, 0),
111 )
112 for lang in sorted(lang_files, key=lambda l: lang_files[l], reverse=True)
113 ]
114 return stats
115
116 def _build_symbol_kind_counts(symbol_map: SymbolTreeIndex) -> list[SymbolKindCount]:
117 """Count symbols by kind across all files in *symbol_map*."""
118 counts: CounterMap = {}
119 for tree in symbol_map.values():
120 for rec in tree.values():
121 kind = rec["kind"]
122 counts[kind] = counts.get(kind, 0) + 1
123 return [
124 SymbolKindCount(kind=k, count=counts[k])
125 for k in sorted(counts, key=lambda k: counts[k], reverse=True)
126 ]
127
128 def _api_surface(
129 root: pathlib.Path,
130 manifest: Manifest,
131 ) -> ApiSurface:
132 """Return a flat map of public-symbol address → (language, SymbolRecord)."""
133 surface: ApiSurface = {}
134 sym_map = symbols_for_snapshot(root, manifest)
135 for file_path, tree in sym_map.items():
136 lang = language_of(file_path)
137 for address, rec in tree.items():
138 if _is_public_symbol(rec["name"], rec["kind"]):
139 surface[address] = (lang, rec)
140 return surface
141
142 def _build_api_changes(
143 prev_surface: ApiSurface,
144 curr_surface: ApiSurface,
145 max_changes: int = 200,
146 ) -> tuple[list[ApiChangeSummary], list[ApiChangeSummary], list[ApiChangeSummary]]:
147 """Diff two API surfaces.
148
149 Returns ``(added, removed, modified)`` lists capped at *max_changes* each.
150 A symbol is "modified" when its ``signature_id`` changed (public contract
151 change) or its ``content_id`` changed but ``signature_id`` is the same
152 (implementation change also surfaced, but ranked lower).
153 """
154 added: list[ApiChangeSummary] = []
155 removed: list[ApiChangeSummary] = []
156 modified: list[ApiChangeSummary] = []
157
158 all_addresses = set(prev_surface) | set(curr_surface)
159 for address in sorted(all_addresses):
160 if address not in prev_surface:
161 lang, rec = curr_surface[address]
162 added.append(ApiChangeSummary(
163 address=address, language=lang, kind=rec["kind"], change="added",
164 ))
165 elif address not in curr_surface:
166 lang, rec = prev_surface[address]
167 removed.append(ApiChangeSummary(
168 address=address, language=lang, kind=rec["kind"], change="removed",
169 ))
170 else:
171 prev_rec = prev_surface[address][1]
172 curr_rec = curr_surface[address][1]
173 if prev_rec["content_id"] != curr_rec["content_id"]:
174 lang = curr_surface[address][0]
175 change = "modified"
176 modified.append(ApiChangeSummary(
177 address=address, language=lang, kind=curr_rec["kind"], change=change,
178 ))
179
180 return added[:max_changes], removed[:max_changes], modified[:max_changes]
181
182 def _is_patch_op(op: DomainOp) -> bool:
183 return op["op"] == "patch"
184
185 def _build_file_hotspots(
186 changelog: list[ChangelogEntry],
187 structured_deltas: list[list[DomainOp]],
188 max_hotspots: int = 10,
189 ) -> list[FileHotspot]:
190 """Count how many times each file was touched across this release's commits."""
191 churn: CounterMap = {}
192 for delta in structured_deltas:
193 for file_path in touched_files(delta):
194 churn[file_path] = churn.get(file_path, 0) + 1
195 # Also count non-patch top-level ops (whole-file add/delete).
196 for op in delta:
197 if not _is_patch_op(op):
198 addr = op["address"]
199 churn[addr] = churn.get(addr, 0) + 1
200
201 top = sorted(churn, key=lambda p: churn[p], reverse=True)[:max_hotspots]
202 return [
203 FileHotspot(
204 file_path=p,
205 change_count=churn[p],
206 language=language_of(p),
207 )
208 for p in top
209 ]
210
211 def _build_refactor_events(
212 changelog: list[ChangelogEntry],
213 structured_deltas: list[list[DomainOp]],
214 max_events: int = 50,
215 ) -> list[RefactorEventSummary]:
216 """Extract structural refactoring events from commit structured_deltas."""
217 events: list[RefactorEventSummary] = []
218 for entry, delta in zip(changelog, structured_deltas):
219 cid = entry["commit_id"]
220 for op in delta:
221 if op["op"] == "rename":
222 events.append(RefactorEventSummary(
223 kind="move",
224 address=op["address"],
225 detail=f"renamed from {op['from_address']}",
226 commit_id=cid,
227 ))
228 elif op["op"] == "insert":
229 addr = op["address"]
230 if "/" in addr: # file-level insert = new file
231 events.append(RefactorEventSummary(
232 kind="insert",
233 address=addr,
234 detail=op.get("content_summary", ""),
235 commit_id=cid,
236 ))
237 elif op["op"] == "delete":
238 addr = op["address"]
239 if "/" in addr: # file-level delete = removed file
240 events.append(RefactorEventSummary(
241 kind="delete",
242 address=addr,
243 detail=op.get("content_summary", ""),
244 commit_id=cid,
245 ))
246 # Symbol-level renames: same body_hash, different name
247 for sym_op in flat_symbol_ops(delta):
248 if sym_op["op"] == "insert":
249 events.append(RefactorEventSummary(
250 kind="insert",
251 address=sym_op["address"],
252 detail=sym_op.get("content_summary", ""),
253 commit_id=cid,
254 ))
255 elif sym_op["op"] == "delete":
256 events.append(RefactorEventSummary(
257 kind="delete",
258 address=sym_op["address"],
259 detail=sym_op.get("content_summary", ""),
260 commit_id=cid,
261 ))
262 if len(events) >= max_events:
263 break
264 return events[:max_events]
265
266 # ---------------------------------------------------------------------------
267 # Public API
268 # ---------------------------------------------------------------------------
269
270 def compute_release_analysis(
271 root: pathlib.Path,
272 release: ReleaseRecord,
273 prev_snapshot_id: str | None = None,
274 ) -> SemanticReleaseReport:
275 """Compute the full semantic analysis for *release*.
276
277 Args:
278 root: Repository root (must contain ``.muse/``).
279 release: The release whose tip snapshot is being analysed.
280 prev_snapshot_id: Snapshot ID of the previous release. When provided,
281 the API surface diff is computed against it; otherwise
282 all public symbols are reported as "added".
283
284 Returns:
285 A fully populated :class:`~muse.core.store.SemanticReleaseReport`.
286 On any error the function returns an empty report rather than raising,
287 so a transient analysis failure never blocks a push.
288 """
289 try:
290 return _compute(root, release, prev_snapshot_id)
291 except Exception:
292 logger.warning(
293 "⚠️ Semantic analysis failed for release %s — attaching empty report.",
294 release.tag,
295 exc_info=True,
296 )
297 return _empty_report()
298
299 def _compute(
300 root: pathlib.Path,
301 release: ReleaseRecord,
302 prev_snapshot_id: str | None,
303 ) -> SemanticReleaseReport:
304 # -- Snapshot manifest for current release ---------------------------------
305 snap = read_snapshot(root, release.snapshot_id)
306 if snap is None:
307 logger.warning("⚠️ Snapshot %s not found; analysis skipped.", release.snapshot_id)
308 return _empty_report()
309
310 manifest = snap.manifest
311 total_files = len(manifest)
312 semantic_file_count = sum(1 for p in manifest if is_semantic(p))
313
314 # Cap extraction to avoid blocking on huge snapshots.
315 capped_manifest = (
316 dict(list(manifest.items())[:_MAX_SEMANTIC_FILES])
317 if semantic_file_count > _MAX_SEMANTIC_FILES
318 else manifest
319 )
320
321 sym_map = symbols_for_snapshot(root, capped_manifest)
322 total_symbols = sum(len(tree) for tree in sym_map.values())
323 languages = _build_language_stats(manifest, sym_map)
324 symbols_by_kind = _build_symbol_kind_counts(sym_map)
325
326 # -- API surface diff ------------------------------------------------------
327 curr_surface = _api_surface(root, capped_manifest)
328 if prev_snapshot_id:
329 prev_snap = read_snapshot(root, prev_snapshot_id)
330 prev_manifest = prev_snap.manifest if prev_snap else {}
331 capped_prev = (
332 dict(list(prev_manifest.items())[:_MAX_SEMANTIC_FILES])
333 if len(prev_manifest) > _MAX_SEMANTIC_FILES
334 else prev_manifest
335 )
336 prev_surface = _api_surface(root, capped_prev)
337 else:
338 prev_surface = {}
339
340 api_added, api_removed, api_modified = _build_api_changes(prev_surface, curr_surface)
341
342 # -- Commit-level analysis from structured_deltas ---------------------------
343 repo_id = release.repo_id
344 changelog = release.changelog
345 commits = walk_commits_between(root, release.commit_id, None, max_commits=500)
346 # Align commits to changelog (newest-first from walk, oldest-first in changelog).
347 commit_map = {c.commit_id: c for c in commits}
348 structured_deltas: list[list[DomainOp]] = []
349 for entry in changelog:
350 commit = commit_map.get(entry["commit_id"])
351 if commit and commit.structured_delta:
352 structured_deltas.append(commit.structured_delta["ops"])
353 else:
354 structured_deltas.append([])
355
356 files_changed = len({
357 p
358 for delta in structured_deltas
359 for p in touched_files(delta)
360 })
361
362 file_hotspots = _build_file_hotspots(changelog, structured_deltas)
363 refactor_events = _build_refactor_events(changelog, structured_deltas)
364
365 # -- Provenance aggregation ------------------------------------------------
366 breaking: list[str] = []
367 seen_bc: set[str] = set()
368 human_commits = 0
369 agent_commits = 0
370 agents: set[str] = set()
371 models: set[str] = set()
372 reviewers_set: set[str] = set()
373
374 for entry in changelog:
375 for bc in entry.get("breaking_changes", []):
376 if bc not in seen_bc:
377 seen_bc.add(bc)
378 breaking.append(bc)
379 aid = entry.get("agent_id", "")
380 mid = entry.get("model_id", "")
381 if aid:
382 agent_commits += 1
383 agents.add(aid)
384 else:
385 human_commits += 1
386 if mid:
387 models.add(mid)
388
389 for c in commits:
390 for reviewer in c.reviewed_by:
391 reviewers_set.add(reviewer)
392
393 return SemanticReleaseReport(
394 languages=languages,
395 total_files=total_files,
396 semantic_files=semantic_file_count,
397 total_symbols=total_symbols,
398 symbols_by_kind=symbols_by_kind,
399 files_changed=files_changed,
400 api_added=api_added,
401 api_removed=api_removed,
402 api_modified=api_modified,
403 file_hotspots=file_hotspots,
404 refactor_events=refactor_events,
405 breaking_changes=breaking,
406 human_commits=human_commits,
407 agent_commits=agent_commits,
408 unique_agents=sorted(agents),
409 unique_models=sorted(models),
410 reviewers=sorted(reviewers_set),
411 )
File History 1 commit
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d fix: migration must never update identity.toml on a failed … Sonnet 5 minor 9 hours ago