breakage.py python
490 lines 18.8 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 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._version import __version__
61 from muse.core.repo import read_repo_id, require_repo
62 from muse.core.store import (
63 Manifest,
64 get_commit_snapshot_manifest,
65 get_head_commit_id,
66 read_current_branch,
67 resolve_commit_ref,
68 )
69 from muse.core.symbol_cache import load_symbol_cache
70 from muse.plugins.code._query import is_semantic, language_of, symbols_for_snapshot
71 from muse.plugins.code.ast_parser import SymbolTree
72 from muse.core.validation import sanitize_display
73
74 type _SymbolTreeMap = dict[str, SymbolTree]
75 type _MethodMap = dict[str, set[str]]
76
77 logger = logging.getLogger(__name__)
78
79
80 # ---------------------------------------------------------------------------
81 # Data types
82 # ---------------------------------------------------------------------------
83
84 class _BreakageIssue(TypedDict):
85 """One breakage finding, serialisable to JSON."""
86
87 issue_type: str
88 file_path: str
89 description: str
90 severity: str # "error" | "warning"
91
92
93 # ---------------------------------------------------------------------------
94 # Index helpers
95 # ---------------------------------------------------------------------------
96
97 def _build_head_names_set(head_sym_map: _SymbolTreeMap) -> set[str]:
98 """Return the set of all non-import symbol *names* across HEAD.
99
100 Used for O(1) stale-import lookup: a working-tree import is stale if and
101 only if the imported name is absent from this set (and also not defined
102 locally in the working-tree file).
103 """
104 names: set[str] = set()
105 for tree in head_sym_map.values():
106 for rec in tree.values():
107 if rec["kind"] != "import":
108 names.add(rec["name"])
109 return names
110
111
112 def _build_head_class_methods(
113 head_sym_map: _SymbolTreeMap,
114 ) -> _MethodMap:
115 """Return a map of ``"file_path::ClassName"`` → ``{method_name, ...}`` from HEAD.
116
117 Used for Check 2: a class that drops a public method it had in HEAD is
118 flagged as a ``removed_public_method`` breakage.
119 """
120 class_methods: _MethodMap = {}
121 for fp, tree in head_sym_map.items():
122 for rec in tree.values():
123 if rec["kind"] != "method":
124 continue
125 qn: str = rec["qualified_name"]
126 if "." not in qn:
127 continue
128 class_part, _ = qn.rsplit(".", 1)
129 key = f"{fp}::{class_part}"
130 class_methods.setdefault(key, set()).add(rec["name"])
131 return class_methods
132
133
134 # ---------------------------------------------------------------------------
135 # Per-file analysis
136 # ---------------------------------------------------------------------------
137
138 def _check_file(
139 file_path: str,
140 working_tree: SymbolTree,
141 head_tree: SymbolTree,
142 head_names_set: set[str],
143 head_class_methods: _MethodMap,
144 head_file_paths: frozenset[str],
145 ) -> list[_BreakageIssue]:
146 """Return all breakage issues for one file.
147
148 Args:
149 file_path: Workspace-relative POSIX path.
150 working_tree: Symbols parsed from the working-tree version of the
151 file (may be empty if the file is new or not
152 semantic).
153 head_tree: Symbols parsed from the HEAD-committed version of
154 the file (empty if the file is new).
155 head_names_set: O(1)-lookup set of all non-import symbol names in
156 the entire HEAD snapshot.
157 head_class_methods: ``"file::Class"`` → public method names in HEAD,
158 used to detect removed methods.
159 head_file_paths: O(1)-lookup set of all file paths in the HEAD
160 snapshot; used to distinguish module imports (e.g.
161 ``from muse.cli.commands import breakage``) from
162 symbol imports so they are not falsely flagged as
163 stale.
164 """
165 if not working_tree:
166 return []
167
168 issues: list[_BreakageIssue] = []
169
170 # Names defined locally in the working-tree file (non-import symbols).
171 local_names: set[str] = {
172 rec["name"]
173 for rec in working_tree.values()
174 if rec["kind"] != "import"
175 }
176
177 # -----------------------------------------------------------------------
178 # Check 1: stale imports
179 # -----------------------------------------------------------------------
180 # A working-tree import is stale when its name exists neither in the HEAD
181 # snapshot (anywhere — we use a codebase-wide set for speed) nor is it
182 # defined locally in the same file, AND it does not resolve to a known
183 # module file in the HEAD snapshot.
184 #
185 # Only muse-internal imports are checked. Stdlib, third-party, __future__,
186 # and typing imports are deliberately excluded — they live outside the
187 # Muse symbol graph and can never go "stale" by definition.
188 #
189 # The qualified_name written by the AST parser is:
190 # "import::<module>::<name>" — from <module> import <name>
191 # "import::<name>" — import <name> (module IS the name)
192 # A muse-internal import is one where <module> starts with "muse." or
193 # equals "muse", or (for bare `import muse.X`) the name starts with "muse.".
194 #
195 # Module-import disambiguation: ``from muse.cli.commands import breakage``
196 # records name="breakage" and source_module="muse.cli.commands". The name
197 # "breakage" will never appear in ``head_names_set`` (which contains symbol
198 # names, not module names), so without a module check it would be a false
199 # positive. We convert source_module to a filesystem path and check
200 # whether ``{path}/{name}.py`` or ``{path}/{name}/__init__.py`` is a known
201 # file in the HEAD snapshot — if so, the import targets a module, not a
202 # symbol, and is valid.
203 #
204 # Complexity: O(1) per import — all lookups hit frozenset/set.
205 for rec in working_tree.values():
206 if rec["kind"] != "import":
207 continue
208 name: str = rec["name"]
209 if name.startswith("*:"):
210 continue # wildcard imports — cannot check statically
211
212 # Determine source module from the qualified_name written by ast_parser.
213 # Format: "import::<module>::<name>" or "import::<name>".
214 qn: str = rec.get("qualified_name", "")
215 parts = qn.split("::")
216 if len(parts) == 3:
217 # from <module> import <name>
218 source_module = parts[1]
219 else:
220 # bare `import <name>` — the name IS the module
221 source_module = name
222
223 # Skip anything that is not a muse-internal import.
224 if source_module != "muse" and not source_module.startswith("muse."):
225 continue
226
227 # ``parts[2]`` is the *original* pre-alias name stored in qualified_name
228 # by the AST parser. For ``from muse.core.store import CommitRecord as
229 # MuseCliCommit``, parts[2]="CommitRecord" and name="MuseCliCommit".
230 # We must look up the original in the HEAD snapshot — the alias will
231 # never appear as a top-level symbol anywhere.
232 original = parts[2] if len(parts) == 3 else name
233
234 if original not in head_names_set and name not in local_names:
235 # Check whether the original name resolves to a submodule file.
236 #
237 # Two cases:
238 #
239 # 1. ``from muse.cli.commands import age`` (len==3):
240 # source_module="muse.cli.commands", original="age"
241 # → check "muse/cli/commands/age.py"
242 #
243 # 2. ``import muse.core.rebase`` (len!=3, bare import):
244 # source_module=name=original="muse.core.rebase"
245 # → the dotted name IS the full module path
246 # → check "muse/core/rebase.py" directly (not appending again)
247 module_dir = source_module.replace(".", "/")
248 if len(parts) == 3:
249 is_module = (
250 f"{module_dir}/{original}.py" in head_file_paths
251 or f"{module_dir}/{original}/__init__.py" in head_file_paths
252 )
253 else:
254 # bare import: module_dir already encodes the full path
255 is_module = (
256 f"{module_dir}.py" in head_file_paths
257 or f"{module_dir}/__init__.py" in head_file_paths
258 )
259 if is_module:
260 continue # valid module import — not stale
261
262 issues.append(
263 _BreakageIssue(
264 issue_type="stale_import",
265 file_path=file_path,
266 description=(
267 f"imports '{original}'"
268 + (f" (as '{name}')" if name != original else "")
269 + " but no symbol or module with that name "
270 "exists in the HEAD snapshot"
271 ),
272 severity="warning",
273 )
274 )
275
276 # -----------------------------------------------------------------------
277 # Check 2: removed public methods
278 # -----------------------------------------------------------------------
279 # For each class that appears in BOTH HEAD and the working tree, flag any
280 # public method that existed in HEAD but is missing from the working-tree
281 # class body. Private methods (``_``-prefixed) are intentionally excluded
282 # — they are implementation detail, not public API.
283 #
284 # Only applies to Python / Python-stub files; other adapters may not
285 # produce reliable method records.
286 suffix = pathlib.PurePosixPath(file_path).suffix.lower()
287 if suffix in {".py", ".pyi"} and head_tree:
288 # Build working-tree class → methods map for this file.
289 working_class_methods: _MethodMap = {}
290 for rec in working_tree.values():
291 if rec["kind"] != "method":
292 continue
293 qn = rec["qualified_name"]
294 if "." not in qn:
295 continue
296 class_part, _ = qn.rsplit(".", 1)
297 working_class_methods.setdefault(class_part, set()).add(rec["name"])
298
299 for rec in head_tree.values():
300 if rec["kind"] != "class":
301 continue
302 class_name: str = rec["name"]
303 head_key = f"{file_path}::{class_name}"
304 expected = head_class_methods.get(head_key, set())
305 actual = working_class_methods.get(class_name, set())
306 for method in sorted(expected - actual):
307 if not method.startswith("_"):
308 issues.append(
309 _BreakageIssue(
310 issue_type="removed_public_method",
311 file_path=file_path,
312 description=(
313 f"class '{class_name}' is missing public method "
314 f"'{method}' that existed in HEAD"
315 ),
316 severity="error",
317 )
318 )
319
320 return issues
321
322
323 # ---------------------------------------------------------------------------
324 # CLI plumbing
325 # ---------------------------------------------------------------------------
326
327 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
328 """Register the ``breakage`` subcommand."""
329 parser = subparsers.add_parser(
330 "breakage",
331 help="Detect symbol-level breakage in the working tree vs HEAD snapshot.",
332 description=__doc__,
333 formatter_class=argparse.RawDescriptionHelpFormatter,
334 )
335 parser.add_argument(
336 "--language", "-l",
337 default=None, metavar="LANG", dest="language",
338 help="Restrict to files of this language (e.g. Python).",
339 )
340 parser.add_argument(
341 "--commit", "-c",
342 default=None, metavar="REF", dest="commit_ref",
343 help="Check against this commit/branch/tag instead of HEAD.",
344 )
345 parser.add_argument(
346 "--path", "-p",
347 default=None, metavar="PATTERN", dest="path_filter",
348 help="Only check files matching this glob pattern (e.g. 'muse/core/*.py').",
349 )
350 parser.add_argument(
351 "--strict",
352 action="store_true", dest="strict",
353 help="Exit non-zero if any warnings are found (not just errors).",
354 )
355 parser.add_argument(
356 "--json",
357 action="store_true", dest="as_json",
358 help="Emit results as JSON.",
359 )
360 parser.set_defaults(func=run)
361
362
363 def run(args: argparse.Namespace) -> None:
364 """Detect symbol-level breakage in the working tree.
365
366 Exits with code 0 when no errors are found (warnings are tolerated unless
367 ``--strict`` is set). Exits with code 1 when errors are present, or when
368 ``--strict`` is set and warnings are present.
369 """
370 language: str | None = args.language
371 as_json: bool = args.as_json
372 commit_ref: str | None = getattr(args, "commit_ref", None)
373 path_filter: str | None = getattr(args, "path_filter", None)
374 strict: bool = getattr(args, "strict", False)
375
376 root = require_repo()
377 repo_id = read_repo_id(root)
378 branch = read_current_branch(root)
379
380 # Resolve branch names before calling resolve_commit_ref, which only
381 # handles commit SHAs and HEAD~N notation.
382 resolved_ref: str | None = commit_ref
383 if commit_ref is not None:
384 branch_head = get_head_commit_id(root, commit_ref)
385 if branch_head is not None:
386 resolved_ref = branch_head # promote to full commit ID
387
388 commit = resolve_commit_ref(root, repo_id, branch, resolved_ref)
389 if commit is None:
390 ref_label = commit_ref or "HEAD"
391 print(f"❌ No commit found for ref '{ref_label}'.", file=sys.stderr)
392 raise SystemExit(1)
393
394 manifest = get_commit_snapshot_manifest(root, commit.commit_id)
395 if manifest is None:
396 print(
397 f"❌ Cannot read snapshot for commit {commit.commit_id[:8]} — "
398 "repository may be corrupt.",
399 file=sys.stderr,
400 )
401 raise SystemExit(1)
402
403 # Apply glob path filter before loading symbols (avoids parsing unused files).
404 filtered_manifest: Manifest = (
405 {fp: oid for fp, oid in manifest.items() if fnmatch.fnmatch(fp, path_filter)}
406 if path_filter is not None
407 else dict(manifest)
408 )
409
410 # Load HEAD symbols (committed, from object store) and working-tree symbols
411 # (from disk) in a single shared cache cycle to minimise I/O.
412 shared_cache = load_symbol_cache(root)
413 head_sym_map = symbols_for_snapshot(
414 root, filtered_manifest,
415 language_filter=language,
416 cache=shared_cache,
417 )
418 working_sym_map = symbols_for_snapshot(
419 root, filtered_manifest,
420 workdir=root,
421 language_filter=language,
422 cache=shared_cache,
423 )
424 shared_cache.save()
425
426 # Build O(1) indexes once; _check_file uses them per-file.
427 head_names_set = _build_head_names_set(head_sym_map)
428 head_class_methods = _build_head_class_methods(head_sym_map)
429 head_file_paths = frozenset(head_sym_map.keys())
430
431 all_issues: list[_BreakageIssue] = []
432 for file_path in sorted(filtered_manifest.keys()):
433 if not is_semantic(file_path):
434 continue
435 if language and language_of(file_path) != language:
436 continue
437 working_tree = working_sym_map.get(file_path, {})
438 head_tree = head_sym_map.get(file_path, {})
439 issues = _check_file(
440 file_path, working_tree, head_tree,
441 head_names_set, head_class_methods,
442 head_file_paths,
443 )
444 all_issues.extend(issues)
445
446 errors = sum(1 for i in all_issues if i["severity"] == "error")
447 warnings = sum(1 for i in all_issues if i["severity"] == "warning")
448 exit_code = 1 if (errors > 0 or (strict and warnings > 0)) else 0
449
450 if as_json:
451 print(json.dumps(
452 {
453 "schema_version": __version__,
454 "commit": commit.commit_id[:8],
455 "branch": branch,
456 "language_filter": language,
457 "path_filter": path_filter,
458 "strict": strict,
459 "file_count": len(filtered_manifest),
460 "issues": list(all_issues),
461 "total": len(all_issues),
462 "errors": errors,
463 "warnings": warnings,
464 },
465 indent=2,
466 ))
467 raise SystemExit(exit_code)
468
469 ref_label = f"{commit.commit_id[:8]}"
470 if commit_ref:
471 ref_label = f"{commit_ref} ({commit.commit_id[:8]})"
472 print(f"\nBreakage check — working tree vs {ref_label}")
473 if language:
474 print(f" (language: {language})")
475 if path_filter:
476 print(f" (path: {sanitize_display(path_filter)})")
477 print("─" * 62)
478
479 if not all_issues:
480 print("\n ✅ No structural breakage detected.")
481 raise SystemExit(0)
482
483 for issue in all_issues:
484 icon = "🔴" if issue["severity"] == "error" else "⚠️ "
485 print(f"\n{icon} {sanitize_display(issue['issue_type'])}")
486 print(f" {sanitize_display(issue['file_path'])}")
487 print(f" {issue['description']}")
488
489 print(f"\n {errors} error(s), {warnings} warning(s)")
490 raise SystemExit(exit_code)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago