gabriel / muse public
breakage.py python
552 lines 21.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """muse code breakage — detect symbol-level breakage in the working tree.
2
3 Checks the current working tree against a committed snapshot for structural
4 breakage that would fail at runtime or import time:
5
6 1. **stale_import** — a working-tree file imports a name that exists nowhere
7 in the HEAD snapshot (and is also not defined locally). Severity: warning.
8 2. **removed_public_method** — a class that appears in both HEAD and the
9 working tree is missing a public method it had in HEAD. This catches
10 public-API regressions before they break callers. Severity: error.
11
12 Analysis is purely structural — no code is executed, no type checker is
13 invoked. It operates on the committed symbol graph plus a live working-tree
14 parse (results are served from the persistent symbol cache when available,
15 so repeated runs on a warm cache are fast).
16
17 Usage::
18
19 muse code breakage
20 muse code breakage --language Python
21 muse code breakage --path "muse/core/*.py"
22 muse code breakage --commit HEAD~3
23 muse code breakage --strict
24 muse code breakage --json
25
26 Flags:
27
28 ``--language LANG``
29 Restrict analysis to files of this language (e.g. ``Python``).
30
31 ``--path PATTERN``
32 Only check files whose path matches this glob pattern
33 (e.g. ``"muse/core/*.py"``).
34
35 ``--commit REF``
36 Diff against this commit instead of HEAD (branch name, commit ID, or
37 tag). Useful for checking "does my working tree still build cleanly
38 against an older baseline?"
39
40 ``--strict``
41 Treat warnings as errors: exit non-zero if any warning-level issues are
42 found, not just error-level ones.
43
44 ``--json``
45 Emit a machine-readable JSON object. Consumers should check
46 ``$.errors`` and ``$.warnings`` (and respect ``strict``) rather than
47 the exit code alone.
48 """
49
50 from __future__ import annotations
51
52 import argparse
53 import fnmatch
54 import json
55 import logging
56 import pathlib
57 import sys
58 from typing import TypedDict
59
60 from muse.core.envelope import EnvelopeJson, make_envelope
61 from muse.core.timing import start_timer
62 from muse.core._types import short_id
63 from muse.core.repo import read_repo_id, require_repo
64 from muse.core.store import (
65 Manifest,
66 get_commit_snapshot_manifest,
67 get_head_commit_id,
68 read_current_branch,
69 resolve_commit_ref,
70 )
71 from muse.core.symbol_cache import load_symbol_cache
72 from muse.plugins.code._query import is_semantic, language_of, symbols_for_snapshot
73 from muse.plugins.code.ast_parser import SymbolTree
74 from muse.core.validation import sanitize_display
75
76 type _SymbolTreeMap = dict[str, SymbolTree]
77 type _MethodMap = dict[str, set[str]]
78
79 logger = logging.getLogger(__name__)
80
81
82 # ---------------------------------------------------------------------------
83 # Data types
84 # ---------------------------------------------------------------------------
85
86 class _BreakageIssue(TypedDict):
87 """One breakage finding, serialisable to JSON."""
88
89 issue_type: str
90 file_path: str
91 description: str
92 severity: str # "error" | "warning"
93
94
95 class _BreakageOutputJson(EnvelopeJson):
96 """Top-level JSON payload emitted by ``muse code breakage --json``.
97
98 Fields
99 ------
100 commit Short commit ID checked against.
101 branch Current branch name.
102 language_filter Language filter passed via ``--language``, or ``None``.
103 path_filter Glob filter passed via ``--path``, or ``None``.
104 strict Whether ``--strict`` was set.
105 file_count Number of files analysed.
106 issues List of :class:`_BreakageIssue` dicts.
107 total Total issue count (errors + warning_count).
108 errors Number of error-severity issues.
109 warning_count Number of warning-severity issues.
110 """
111
112 commit: str
113 branch: str
114 language_filter: str | None
115 path_filter: str | None
116 strict: bool
117 file_count: int
118 issues: list[_BreakageIssue]
119 total: int
120 errors: int
121 warning_count: int
122
123
124 # ---------------------------------------------------------------------------
125 # Index helpers
126 # ---------------------------------------------------------------------------
127
128 def _build_head_names_set(head_sym_map: _SymbolTreeMap) -> set[str]:
129 """Return the set of all non-import symbol *names* across HEAD.
130
131 Used for O(1) stale-import lookup: a working-tree import is stale if and
132 only if the imported name is absent from this set (and also not defined
133 locally in the working-tree file).
134 """
135 names: set[str] = set()
136 for tree in head_sym_map.values():
137 for rec in tree.values():
138 if rec["kind"] != "import":
139 names.add(rec["name"])
140 return names
141
142
143 def _build_head_class_methods(
144 head_sym_map: _SymbolTreeMap,
145 ) -> _MethodMap:
146 """Return a map of ``"file_path::ClassName"`` → ``{method_name, ...}`` from HEAD.
147
148 Used for Check 2: a class that drops a public method it had in HEAD is
149 flagged as a ``removed_public_method`` breakage.
150 """
151 class_methods: _MethodMap = {}
152 for fp, tree in head_sym_map.items():
153 for rec in tree.values():
154 if rec["kind"] != "method":
155 continue
156 qn: str = rec["qualified_name"]
157 if "." not in qn:
158 continue
159 class_part, _ = qn.rsplit(".", 1)
160 key = f"{fp}::{class_part}"
161 class_methods.setdefault(key, set()).add(rec["name"])
162 return class_methods
163
164
165 # ---------------------------------------------------------------------------
166 # Per-file analysis
167 # ---------------------------------------------------------------------------
168
169 def _check_file(
170 file_path: str,
171 working_tree: SymbolTree,
172 head_tree: SymbolTree,
173 head_names_set: set[str],
174 head_class_methods: _MethodMap,
175 head_file_paths: frozenset[str],
176 ) -> list[_BreakageIssue]:
177 """Return all breakage issues for one file.
178
179 Args:
180 file_path: Workspace-relative POSIX path.
181 working_tree: Symbols parsed from the working-tree version of the
182 file (may be empty if the file is new or not
183 semantic).
184 head_tree: Symbols parsed from the HEAD-committed version of
185 the file (empty if the file is new).
186 head_names_set: O(1)-lookup set of all non-import symbol names in
187 the entire HEAD snapshot.
188 head_class_methods: ``"file::Class"`` → public method names in HEAD,
189 used to detect removed methods.
190 head_file_paths: O(1)-lookup set of all file paths in the HEAD
191 snapshot; used to distinguish module imports (e.g.
192 ``from muse.cli.commands import breakage``) from
193 symbol imports so they are not falsely flagged as
194 stale.
195 """
196 if not working_tree:
197 return []
198
199 issues: list[_BreakageIssue] = []
200
201 # Names defined locally in the working-tree file (non-import symbols).
202 local_names: set[str] = {
203 rec["name"]
204 for rec in working_tree.values()
205 if rec["kind"] != "import"
206 }
207
208 # -----------------------------------------------------------------------
209 # Check 1: stale imports
210 # -----------------------------------------------------------------------
211 # A working-tree import is stale when its name exists neither in the HEAD
212 # snapshot (anywhere — we use a codebase-wide set for speed) nor is it
213 # defined locally in the same file, AND it does not resolve to a known
214 # module file in the HEAD snapshot.
215 #
216 # Only muse-internal imports are checked. Stdlib, third-party, __future__,
217 # and typing imports are deliberately excluded — they live outside the
218 # Muse symbol graph and can never go "stale" by definition.
219 #
220 # The qualified_name written by the AST parser is:
221 # "import::<module>::<name>" — from <module> import <name>
222 # "import::<name>" — import <name> (module IS the name)
223 # A muse-internal import is one where <module> starts with "muse." or
224 # equals "muse", or (for bare `import muse.X`) the name starts with "muse.".
225 #
226 # Module-import disambiguation: ``from muse.cli.commands import breakage``
227 # records name="breakage" and source_module="muse.cli.commands". The name
228 # "breakage" will never appear in ``head_names_set`` (which contains symbol
229 # names, not module names), so without a module check it would be a false
230 # positive. We convert source_module to a filesystem path and check
231 # whether ``{path}/{name}.py`` or ``{path}/{name}/__init__.py`` is a known
232 # file in the HEAD snapshot — if so, the import targets a module, not a
233 # symbol, and is valid.
234 #
235 # Complexity: O(1) per import — all lookups hit frozenset/set.
236 for rec in working_tree.values():
237 if rec["kind"] != "import":
238 continue
239 name: str = rec["name"]
240 if name.startswith("*:"):
241 continue # wildcard imports — cannot check statically
242
243 # Determine source module from the qualified_name written by ast_parser.
244 # Format: "import::<module>::<name>" or "import::<name>".
245 qn: str = rec.get("qualified_name", "")
246 parts = qn.split("::")
247 if len(parts) == 3:
248 # from <module> import <name>
249 source_module = parts[1]
250 else:
251 # bare `import <name>` — the name IS the module
252 source_module = name
253
254 # Skip anything that is not a muse-internal import.
255 if source_module != "muse" and not source_module.startswith("muse."):
256 continue
257
258 # ``parts[2]`` is the *original* pre-alias name stored in qualified_name
259 # by the AST parser. For ``from muse.core.store import CommitRecord as
260 # MuseCliCommit``, parts[2]="CommitRecord" and name="MuseCliCommit".
261 # We must look up the original in the HEAD snapshot — the alias will
262 # never appear as a top-level symbol anywhere.
263 original = parts[2] if len(parts) == 3 else name
264
265 if original not in head_names_set and name not in local_names:
266 # Check whether the original name resolves to a submodule file.
267 #
268 # Two cases:
269 #
270 # 1. ``from muse.cli.commands import age`` (len==3):
271 # source_module="muse.cli.commands", original="age"
272 # → check "muse/cli/commands/age.py"
273 #
274 # 2. ``import muse.core.rebase`` (len!=3, bare import):
275 # source_module=name=original="muse.core.rebase"
276 # → the dotted name IS the full module path
277 # → check "muse/core/rebase.py" directly (not appending again)
278 module_dir = source_module.replace(".", "/")
279 if len(parts) == 3:
280 is_module = (
281 f"{module_dir}/{original}.py" in head_file_paths
282 or f"{module_dir}/{original}/__init__.py" in head_file_paths
283 )
284 else:
285 # bare import: module_dir already encodes the full path
286 is_module = (
287 f"{module_dir}.py" in head_file_paths
288 or f"{module_dir}/__init__.py" in head_file_paths
289 )
290 if is_module:
291 continue # valid module import — not stale
292
293 issues.append(
294 _BreakageIssue(
295 issue_type="stale_import",
296 file_path=file_path,
297 description=(
298 f"imports '{original}'"
299 + (f" (as '{name}')" if name != original else "")
300 + " but no symbol or module with that name "
301 "exists in the HEAD snapshot"
302 ),
303 severity="warning",
304 )
305 )
306
307 # -----------------------------------------------------------------------
308 # Check 2: removed public methods
309 # -----------------------------------------------------------------------
310 # For each class that appears in BOTH HEAD and the working tree, flag any
311 # public method that existed in HEAD but is missing from the working-tree
312 # class body. Private methods (``_``-prefixed) are intentionally excluded
313 # — they are implementation detail, not public API.
314 #
315 # Only applies to Python / Python-stub files; other adapters may not
316 # produce reliable method records.
317 suffix = pathlib.PurePosixPath(file_path).suffix.lower()
318 if suffix in {".py", ".pyi"} and head_tree:
319 # Build working-tree class → methods map for this file.
320 working_class_methods: _MethodMap = {}
321 for rec in working_tree.values():
322 if rec["kind"] != "method":
323 continue
324 qn = rec["qualified_name"]
325 if "." not in qn:
326 continue
327 class_part, _ = qn.rsplit(".", 1)
328 working_class_methods.setdefault(class_part, set()).add(rec["name"])
329
330 for rec in head_tree.values():
331 if rec["kind"] != "class":
332 continue
333 class_name: str = rec["name"]
334 head_key = f"{file_path}::{class_name}"
335 expected = head_class_methods.get(head_key, set())
336 actual = working_class_methods.get(class_name, set())
337 for method in sorted(expected - actual):
338 if not method.startswith("_"):
339 issues.append(
340 _BreakageIssue(
341 issue_type="removed_public_method",
342 file_path=file_path,
343 description=(
344 f"class '{class_name}' is missing public method "
345 f"'{method}' that existed in HEAD"
346 ),
347 severity="error",
348 )
349 )
350
351 return issues
352
353
354 # ---------------------------------------------------------------------------
355 # CLI registration
356 # ---------------------------------------------------------------------------
357
358 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
359 """Register the ``breakage`` subcommand."""
360 parser = subparsers.add_parser(
361 "breakage",
362 help="Detect symbol-level breakage in the working tree vs HEAD snapshot.",
363 description=__doc__,
364 formatter_class=argparse.RawDescriptionHelpFormatter,
365 )
366 parser.add_argument(
367 "--language", "-l",
368 default=None, metavar="LANG", dest="language",
369 help="Restrict to files of this language (e.g. Python).",
370 )
371 parser.add_argument(
372 "--commit", "-c",
373 default=None, metavar="REF", dest="commit_ref",
374 help="Check against this commit/branch/tag instead of HEAD.",
375 )
376 parser.add_argument(
377 "--path", "-p",
378 default=None, metavar="PATTERN", dest="path_filter",
379 help="Only check files matching this glob pattern (e.g. 'muse/core/*.py').",
380 )
381 parser.add_argument(
382 "--strict",
383 action="store_true", dest="strict",
384 help="Exit non-zero if any warnings are found (not just errors).",
385 )
386 parser.add_argument(
387 "--json", "-j",
388 action="store_true", dest="json_out",
389 help="Emit results as JSON.",
390 )
391 parser.set_defaults(func=run)
392
393
394 def run(args: argparse.Namespace) -> None:
395 """Detect symbol-level breakage in the working tree vs the HEAD snapshot.
396
397 Compares the working tree against the committed HEAD snapshot (or a named
398 ref) for two classes of structural breakage: stale imports (a file imports
399 a name that no longer exists in the snapshot) and removed public methods
400 (a class drops a method that callers may depend on). No code is executed —
401 analysis is purely structural.
402
403 Agent quickstart
404 ----------------
405 ::
406
407 muse code breakage --json
408 muse code breakage --language Python --json
409 muse code breakage --path "muse/core/*.py" --json
410 muse code breakage --strict --json
411
412 JSON fields
413 -----------
414 commit Short commit ID checked against.
415 branch Current branch name.
416 language_filter Language filter passed via ``--language``; ``null`` if none.
417 path_filter Glob filter passed via ``--path``; ``null`` if none.
418 strict ``true`` if ``--strict`` was set.
419 file_count Number of files analysed.
420 issues List of issue objects; each has ``issue_type``,
421 ``file_path``, ``description``, and ``severity``
422 (``"error"`` or ``"warning"``).
423 total Total issue count (``errors + warning_count``).
424 errors Number of error-severity issues.
425 warning_count Number of warning-severity issues.
426 exit_code 0 = clean; 1 = errors found (or warnings under ``--strict``).
427
428 Exit codes
429 ----------
430 0 No errors (warnings tolerated unless ``--strict``).
431 1 Errors found; or warnings found when ``--strict`` is active.
432 2 Not inside a Muse repository.
433 """
434 elapsed = start_timer()
435 language: str | None = args.language
436 json_out: bool = args.json_out
437 commit_ref: str | None = getattr(args, "commit_ref", None)
438 path_filter: str | None = getattr(args, "path_filter", None)
439 strict: bool = getattr(args, "strict", False)
440
441 root = require_repo()
442 repo_id = read_repo_id(root)
443 branch = read_current_branch(root)
444
445 # Resolve branch names before calling resolve_commit_ref, which only
446 # handles commit SHAs and HEAD~N notation.
447 resolved_ref: str | None = commit_ref
448 if commit_ref is not None:
449 branch_head = get_head_commit_id(root, commit_ref)
450 if branch_head is not None:
451 resolved_ref = branch_head # promote to full commit ID
452
453 commit = resolve_commit_ref(root, repo_id, branch, resolved_ref)
454 if commit is None:
455 ref_label = commit_ref or "HEAD"
456 print(f"❌ No commit found for ref '{ref_label}'.", file=sys.stderr)
457 raise SystemExit(1)
458
459 manifest = get_commit_snapshot_manifest(root, commit.commit_id)
460 if manifest is None:
461 print(
462 f"❌ Cannot read snapshot for commit {short_id(commit.commit_id)} — "
463 "repository may be corrupt.",
464 file=sys.stderr,
465 )
466 raise SystemExit(1)
467
468 # Apply glob path filter before loading symbols (avoids parsing unused files).
469 filtered_manifest: Manifest = (
470 {fp: oid for fp, oid in manifest.items() if fnmatch.fnmatch(fp, path_filter)}
471 if path_filter is not None
472 else dict(manifest)
473 )
474
475 # Load HEAD symbols (committed, from object store) and working-tree symbols
476 # (from disk) in a single shared cache cycle to minimise I/O.
477 shared_cache = load_symbol_cache(root)
478 head_sym_map = symbols_for_snapshot(
479 root, filtered_manifest,
480 language_filter=language,
481 cache=shared_cache,
482 )
483 working_sym_map = symbols_for_snapshot(
484 root, filtered_manifest,
485 workdir=root,
486 language_filter=language,
487 cache=shared_cache,
488 )
489 shared_cache.save()
490
491 # Build O(1) indexes once; _check_file uses them per-file.
492 head_names_set = _build_head_names_set(head_sym_map)
493 head_class_methods = _build_head_class_methods(head_sym_map)
494 head_file_paths = frozenset(head_sym_map.keys())
495
496 all_issues: list[_BreakageIssue] = []
497 for file_path in sorted(filtered_manifest.keys()):
498 if not is_semantic(file_path):
499 continue
500 if language and language_of(file_path) != language:
501 continue
502 working_tree = working_sym_map.get(file_path, {})
503 head_tree = head_sym_map.get(file_path, {})
504 issues = _check_file(
505 file_path, working_tree, head_tree,
506 head_names_set, head_class_methods,
507 head_file_paths,
508 )
509 all_issues.extend(issues)
510
511 errors = sum(1 for i in all_issues if i["severity"] == "error")
512 warnings = sum(1 for i in all_issues if i["severity"] == "warning")
513 exit_code = 1 if (errors > 0 or (strict and warnings > 0)) else 0
514
515 if json_out:
516 print(json.dumps(_BreakageOutputJson(
517 **make_envelope(elapsed, exit_code=exit_code),
518 commit=short_id(commit.commit_id),
519 branch=branch,
520 language_filter=language,
521 path_filter=path_filter,
522 strict=strict,
523 file_count=len(filtered_manifest),
524 issues=list(all_issues),
525 total=len(all_issues),
526 errors=errors,
527 warning_count=warnings,
528 )))
529 raise SystemExit(exit_code)
530
531 ref_label = short_id(commit.commit_id)
532 if commit_ref:
533 ref_label = f"{commit_ref} ({short_id(commit.commit_id)})"
534 print(f"\nBreakage check — working tree vs {ref_label}")
535 if language:
536 print(f" (language: {language})")
537 if path_filter:
538 print(f" (path: {sanitize_display(path_filter)})")
539 print("─" * 62)
540
541 if not all_issues:
542 print("\n ✅ No structural breakage detected.")
543 raise SystemExit(0)
544
545 for issue in all_issues:
546 icon = "🔴" if issue["severity"] == "error" else "⚠️ "
547 print(f"\n{icon} {sanitize_display(issue['issue_type'])}")
548 print(f" {sanitize_display(issue['file_path'])}")
549 print(f" {issue['description']}")
550
551 print(f"\n {errors} error(s), {warnings} warning(s)")
552 raise SystemExit(exit_code)
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago