check.py
python
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2
fix: remove commit_exists filter from have anchors — server…
Sonnet 4.6
patch
21 days ago
| 1 | """``muse check`` — generic domain invariant enforcement. |
| 2 | |
| 3 | Dispatches to the domain plugin's registered |
| 4 | :class:`~muse.core.invariants.InvariantChecker` and reports all |
| 5 | violations. Works for any domain that has registered a checker. |
| 6 | |
| 7 | Currently supported domains: |
| 8 | |
| 9 | - ``code`` — complexity, circular imports, dead exports, test coverage. |
| 10 | - ``midi`` — polyphony, pitch range, key consistency, parallel fifths. |
| 11 | |
| 12 | Commit reference syntax:: |
| 13 | |
| 14 | muse check # HEAD of current branch |
| 15 | muse check abc1234 # short SHA prefix |
| 16 | muse check HEAD~2 # two commits before HEAD |
| 17 | muse check main # tip of another branch |
| 18 | |
| 19 | Usage:: |
| 20 | |
| 21 | muse check # HEAD, auto-detect domain |
| 22 | muse check abc1234 # specific commit (short or full SHA) |
| 23 | muse check HEAD~2 # ancestor relative to HEAD |
| 24 | muse check --branch dev # HEAD of another branch |
| 25 | muse check --strict # exit 1 on any error-severity violation |
| 26 | muse check --warn # exit 2 on any warning-severity violation |
| 27 | muse check --strict --warn # exit 1 on errors OR 2 on warnings |
| 28 | muse check --base HEAD~1 # report only violations NEW since HEAD~1 |
| 29 | muse check --filter-severity error # show only errors |
| 30 | muse check --filter-rule no_cycles # run only one named rule |
| 31 | muse check --json # machine-readable JSON (all fields) |
| 32 | muse check --summary # one-line pass/fail for scripts |
| 33 | muse check --rules my.toml # custom rules file |
| 34 | |
| 35 | Exit codes:: |
| 36 | |
| 37 | 0 — all rules passed (or no checker registered) |
| 38 | 1 — one or more error-severity violations (requires --strict) |
| 39 | 2 — one or more warning-severity violations (requires --warn) |
| 40 | |
| 41 | Agent use |
| 42 | --------- |
| 43 | |
| 44 | ``--json`` includes ``exit_code`` so agents can gate on results without |
| 45 | relying on the shell exit status:: |
| 46 | |
| 47 | result = json.loads(subprocess.check_output(["muse", "check", "--json"])) |
| 48 | if result["exit_code"] != 0: |
| 49 | ... # gate on result["violations"] for details |
| 50 | |
| 51 | All counts and flags in the JSON payload reflect the post-filter violation |
| 52 | list (after ``--filter-severity``, ``--filter-rule``, ``--filter-path``). |
| 53 | """ |
| 54 | |
| 55 | import argparse |
| 56 | import json |
| 57 | import logging |
| 58 | import pathlib |
| 59 | import sys |
| 60 | from typing import TypedDict |
| 61 | |
| 62 | from muse.core.errors import ExitCode |
| 63 | from muse.core.invariants import BaseReport, InvariantChecker, diff_reports, format_report |
| 64 | from muse.core.repo import require_repo |
| 65 | from muse.core.refs import read_current_branch |
| 66 | from muse.core.commits import resolve_commit_ref |
| 67 | from muse.core.validation import sanitize_display |
| 68 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 69 | from muse.core.timing import start_timer |
| 70 | from muse.plugins.registry import read_domain |
| 71 | |
| 72 | logger = logging.getLogger(__name__) |
| 73 | |
| 74 | # --------------------------------------------------------------------------- |
| 75 | # Typed JSON schema |
| 76 | # --------------------------------------------------------------------------- |
| 77 | |
| 78 | class _CheckJson(EnvelopeJson): |
| 79 | """Machine-readable output of ``muse check --json``. |
| 80 | |
| 81 | All counts and flags reflect the post-filter violation list (i.e. after |
| 82 | ``--filter-severity``, ``--filter-rule``, and ``--filter-path`` have been |
| 83 | applied). ``exit_code`` is in the envelope and mirrors the process exit |
| 84 | status so agents can gate on it without relying on the shell ``$?``. |
| 85 | """ |
| 86 | |
| 87 | commit_id: str |
| 88 | domain: str |
| 89 | rules_checked: int |
| 90 | has_errors: bool |
| 91 | has_warnings: bool |
| 92 | error_count: int |
| 93 | warning_count: int |
| 94 | info_count: int |
| 95 | total_violations: int |
| 96 | violations: list[dict] |
| 97 | base_commit_id: str | None |
| 98 | |
| 99 | # --------------------------------------------------------------------------- |
| 100 | # Helpers |
| 101 | # --------------------------------------------------------------------------- |
| 102 | |
| 103 | def _get_checker(domain: str) -> InvariantChecker | None: |
| 104 | """Return the domain's InvariantChecker instance, or None. |
| 105 | |
| 106 | Lazy-imports the domain checker to keep startup cost near-zero for repos |
| 107 | in domains that don't need checking. |
| 108 | """ |
| 109 | if domain == "code": |
| 110 | from muse.plugins.code._invariants import CodeChecker |
| 111 | return CodeChecker() |
| 112 | if domain == "midi": |
| 113 | from muse.plugins.midi._invariants import MidiChecker |
| 114 | return MidiChecker() |
| 115 | return None |
| 116 | |
| 117 | def _resolve_ref( |
| 118 | root: pathlib.Path, |
| 119 | ref: str | None, |
| 120 | branch: str, |
| 121 | ) -> str | None: |
| 122 | """Resolve *ref* (short SHA, HEAD~N, full SHA, or None → HEAD) to a commit ID. |
| 123 | |
| 124 | Uses the full ``resolve_commit_ref`` machinery from the store layer so that |
| 125 | all reference syntax works consistently across all muse commands. |
| 126 | """ |
| 127 | commit = resolve_commit_ref(root, branch, ref) |
| 128 | return commit.commit_id if commit is not None else None |
| 129 | |
| 130 | def _filter_report( |
| 131 | report: BaseReport, |
| 132 | *, |
| 133 | filter_severity: str | None, |
| 134 | filter_rule: str | None, |
| 135 | filter_path: str | None, |
| 136 | ) -> BaseReport: |
| 137 | """Return a copy of *report* with violations filtered by severity/rule/path.""" |
| 138 | import fnmatch |
| 139 | from muse.core.invariants import make_report |
| 140 | |
| 141 | violations = report["violations"] |
| 142 | if filter_severity: |
| 143 | violations = [v for v in violations if v["severity"] == filter_severity] |
| 144 | if filter_rule: |
| 145 | violations = [v for v in violations if v["rule_name"] == filter_rule] |
| 146 | if filter_path: |
| 147 | violations = [v for v in violations if fnmatch.fnmatch(v["address"], filter_path)] |
| 148 | return make_report( |
| 149 | report["commit_id"], |
| 150 | report["domain"], |
| 151 | violations, |
| 152 | report["rules_checked"], |
| 153 | ) |
| 154 | |
| 155 | # --------------------------------------------------------------------------- |
| 156 | # Registration |
| 157 | # --------------------------------------------------------------------------- |
| 158 | |
| 159 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 160 | """Register the ``check`` subcommand on *subparsers*.""" |
| 161 | parser = subparsers.add_parser( |
| 162 | "check", |
| 163 | help="Run invariant checks for the current domain against a commit.", |
| 164 | description=__doc__, |
| 165 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 166 | ) |
| 167 | parser.add_argument( |
| 168 | "commit_arg", |
| 169 | nargs="?", |
| 170 | default=None, |
| 171 | metavar="COMMIT", |
| 172 | help=( |
| 173 | "Commit to check: full/short SHA, HEAD~N, or branch name. " |
| 174 | "Defaults to HEAD of the current branch." |
| 175 | ), |
| 176 | ) |
| 177 | parser.add_argument( |
| 178 | "--branch", "-b", |
| 179 | default=None, |
| 180 | dest="branch", |
| 181 | metavar="BRANCH", |
| 182 | help="Branch whose HEAD to check (defaults to the current branch).", |
| 183 | ) |
| 184 | parser.add_argument( |
| 185 | "--base", |
| 186 | default=None, |
| 187 | dest="base_ref", |
| 188 | metavar="REF", |
| 189 | help=( |
| 190 | "Compare against a base commit and report only NEW violations. " |
| 191 | "Accepts the same ref syntax as COMMIT (HEAD~1, branch name, short SHA)." |
| 192 | ), |
| 193 | ) |
| 194 | parser.add_argument( |
| 195 | "--strict", |
| 196 | action="store_true", |
| 197 | help="Exit 1 when any error-severity violation is found.", |
| 198 | ) |
| 199 | parser.add_argument( |
| 200 | "--warn", |
| 201 | action="store_true", |
| 202 | help="Exit 2 when any warning-severity violation is found. Combine with --strict to also gate on errors.", |
| 203 | ) |
| 204 | parser.add_argument( |
| 205 | "--filter-severity", |
| 206 | default=None, |
| 207 | dest="filter_severity", |
| 208 | choices=("error", "warning", "info"), |
| 209 | metavar="SEVERITY", |
| 210 | help="Show only violations at this severity level (error|warning|info).", |
| 211 | ) |
| 212 | parser.add_argument( |
| 213 | "--filter-rule", |
| 214 | default=None, |
| 215 | dest="filter_rule", |
| 216 | metavar="RULE", |
| 217 | help="Show only violations from this named rule.", |
| 218 | ) |
| 219 | parser.add_argument( |
| 220 | "--filter-path", |
| 221 | default=None, |
| 222 | dest="filter_path", |
| 223 | metavar="GLOB", |
| 224 | help="Show only violations whose address matches this fnmatch glob.", |
| 225 | ) |
| 226 | parser.add_argument( |
| 227 | "--rules", |
| 228 | default=None, |
| 229 | dest="rules_file", |
| 230 | metavar="FILE", |
| 231 | help="Path to a TOML invariants file (overrides the domain default).", |
| 232 | ) |
| 233 | parser.add_argument( |
| 234 | "--summary", |
| 235 | action="store_true", |
| 236 | help="Print a single pass/fail summary line and exit (no violation details).", |
| 237 | ) |
| 238 | parser.add_argument( |
| 239 | "--json", "-j", |
| 240 | action="store_true", |
| 241 | dest="json_out", |
| 242 | help="Emit JSON output to stdout.", |
| 243 | ) |
| 244 | parser.set_defaults(func=run) |
| 245 | |
| 246 | # --------------------------------------------------------------------------- |
| 247 | # Command implementation |
| 248 | # --------------------------------------------------------------------------- |
| 249 | |
| 250 | def run(args: argparse.Namespace) -> None: |
| 251 | """Run domain invariant checks against a commit. |
| 252 | |
| 253 | Resolves the target commit (short SHA, HEAD~N, branch name) and dispatches |
| 254 | to the domain's registered invariant checker. Use ``--base REF`` to report |
| 255 | only violations that are new relative to a baseline. Use ``--filter-*`` |
| 256 | flags to narrow the violation list for CI gates that care about a subset. |
| 257 | |
| 258 | Agent quickstart |
| 259 | ---------------- |
| 260 | :: |
| 261 | |
| 262 | muse check --json |
| 263 | muse check --base HEAD~1 --json |
| 264 | muse check --filter-severity error --strict --json |
| 265 | |
| 266 | JSON fields |
| 267 | ----------- |
| 268 | commit_id Full commit ID checked. |
| 269 | domain Repository domain (e.g. ``"code"``). |
| 270 | violations List of violation objects: ``rule``, ``path``, ``message``, |
| 271 | ``severity`` (``"error"`` or ``"warning"``). |
| 272 | error_count Number of error-severity violations. |
| 273 | warning_count Number of warning-severity violations. |
| 274 | exit_code 0 = clean; 1 = violations found (or warnings under ``--strict``). |
| 275 | |
| 276 | Exit codes |
| 277 | ---------- |
| 278 | 0 No violations (or only warnings without ``--strict``). |
| 279 | 1 Violations found; or warnings found with ``--strict``. |
| 280 | 2 Not inside a Muse repository. |
| 281 | """ |
| 282 | elapsed = start_timer() |
| 283 | |
| 284 | commit_arg: str | None = args.commit_arg |
| 285 | branch_arg: str | None = args.branch |
| 286 | base_ref: str | None = args.base_ref |
| 287 | strict: bool = args.strict |
| 288 | warn_flag: bool = args.warn |
| 289 | filter_severity: str | None = args.filter_severity |
| 290 | filter_rule: str | None = args.filter_rule |
| 291 | filter_path: str | None = args.filter_path |
| 292 | rules_file: str | None = args.rules_file |
| 293 | summary_only: bool = args.summary |
| 294 | json_out: bool = args.json_out |
| 295 | |
| 296 | root = require_repo() |
| 297 | domain = read_domain(root) |
| 298 | |
| 299 | # Determine branch context. |
| 300 | branch = branch_arg or read_current_branch(root) |
| 301 | |
| 302 | # Resolve target commit — full ref syntax (HEAD~N, short SHA, branch tip). |
| 303 | commit_id = _resolve_ref(root, commit_arg, branch) |
| 304 | if commit_id is None: |
| 305 | msg = f"Cannot resolve ref {commit_arg!r}" if commit_arg else "No commits on this branch yet" |
| 306 | if json_out: |
| 307 | print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) |
| 308 | else: |
| 309 | print(f"❌ {sanitize_display(msg)}.", file=sys.stderr) |
| 310 | raise SystemExit(ExitCode.USER_ERROR) |
| 311 | |
| 312 | # Validate rules_file path (no directory traversal). |
| 313 | rules_path: pathlib.Path | None = None |
| 314 | if rules_file is not None: |
| 315 | rules_path = pathlib.Path(rules_file) |
| 316 | if not rules_path.is_absolute(): |
| 317 | rules_path = root / rules_path |
| 318 | # Contain within the repo root — reject anything that resolves outside. |
| 319 | try: |
| 320 | rules_path.resolve().relative_to(root.resolve()) |
| 321 | except ValueError: |
| 322 | msg = f"--rules path {rules_file!r} is outside the repository" |
| 323 | if json_out: |
| 324 | print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) |
| 325 | else: |
| 326 | print(f"❌ {sanitize_display(msg)}.", file=sys.stderr) |
| 327 | raise SystemExit(ExitCode.USER_ERROR) |
| 328 | |
| 329 | checker = _get_checker(domain) |
| 330 | if checker is None: |
| 331 | msg = f"No invariant checker registered for domain {sanitize_display(domain)!r}. Supported: code, midi" |
| 332 | if json_out: |
| 333 | print(json.dumps({"error": msg, "exit_code": 0})) |
| 334 | else: |
| 335 | print(f"⚠️ {msg}.", file=sys.stderr) |
| 336 | raise SystemExit(0) |
| 337 | |
| 338 | # Run the checker. |
| 339 | report = checker.check(root, commit_id, rules_file=rules_path) |
| 340 | |
| 341 | # Diff mode: strip violations already present in the base commit. |
| 342 | base_commit_id: str | None = None |
| 343 | if base_ref is not None: |
| 344 | base_commit_id = _resolve_ref(root, base_ref, branch) |
| 345 | if base_commit_id is None: |
| 346 | msg = f"Cannot resolve base ref {base_ref!r}" |
| 347 | if json_out: |
| 348 | print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) |
| 349 | else: |
| 350 | print(f"❌ {sanitize_display(msg)}.", file=sys.stderr) |
| 351 | raise SystemExit(ExitCode.USER_ERROR) |
| 352 | base_report = checker.check(root, base_commit_id, rules_file=rules_path) |
| 353 | report = diff_reports(report, base_report) |
| 354 | |
| 355 | # Apply post-run filters (severity/rule/path narrowing). |
| 356 | has_active_filter = filter_severity or filter_rule or filter_path |
| 357 | if has_active_filter: |
| 358 | report = _filter_report( |
| 359 | report, |
| 360 | filter_severity=filter_severity, |
| 361 | filter_rule=filter_rule, |
| 362 | filter_path=filter_path, |
| 363 | ) |
| 364 | |
| 365 | error_count = sum(1 for v in report["violations"] if v["severity"] == "error") |
| 366 | warning_count = sum(1 for v in report["violations"] if v["severity"] == "warning") |
| 367 | info_count = sum(1 for v in report["violations"] if v["severity"] == "info") |
| 368 | |
| 369 | # Determine exit code before output so agents get it in JSON. |
| 370 | exit_code = 0 |
| 371 | if strict and error_count: |
| 372 | exit_code = 1 |
| 373 | if warn_flag and warning_count: |
| 374 | exit_code = max(exit_code, 2) |
| 375 | |
| 376 | # ── JSON output ─────────────────────────────────────────────────────────── |
| 377 | if json_out: |
| 378 | print(json.dumps(_CheckJson( |
| 379 | **make_envelope(elapsed, exit_code=exit_code), |
| 380 | commit_id=commit_id, |
| 381 | domain=domain, |
| 382 | rules_checked=report["rules_checked"], |
| 383 | has_errors=report["has_errors"], |
| 384 | has_warnings=report["has_warnings"], |
| 385 | error_count=error_count, |
| 386 | warning_count=warning_count, |
| 387 | info_count=info_count, |
| 388 | total_violations=len(report["violations"]), |
| 389 | violations=list(report["violations"]), |
| 390 | base_commit_id=base_commit_id, |
| 391 | ))) |
| 392 | raise SystemExit(exit_code) |
| 393 | |
| 394 | # ── Text output ─────────────────────────────────────────────────────────── |
| 395 | safe_commit = sanitize_display(commit_id) |
| 396 | header = f"\ncheck [{sanitize_display(domain)}] {safe_commit}" |
| 397 | if base_commit_id: |
| 398 | header += f" vs {sanitize_display(base_commit_id)}" |
| 399 | header += f" — {report['rules_checked']} rules" |
| 400 | if has_active_filter: |
| 401 | parts = [] |
| 402 | if filter_severity: |
| 403 | parts.append(f"severity={filter_severity}") |
| 404 | if filter_rule: |
| 405 | parts.append(f"rule={sanitize_display(filter_rule)}") |
| 406 | if filter_path: |
| 407 | parts.append(f"path={sanitize_display(filter_path)}") |
| 408 | header += f" (filtered: {', '.join(parts)})" |
| 409 | print(header) |
| 410 | |
| 411 | if summary_only: |
| 412 | total = len(report["violations"]) |
| 413 | if total == 0: |
| 414 | print(f"✅ No violations.") |
| 415 | else: |
| 416 | print(f"❌ {total} violation(s): {error_count} error(s), {warning_count} warning(s), {info_count} info(s)") |
| 417 | raise SystemExit(exit_code) |
| 418 | |
| 419 | print(format_report(report)) |
| 420 | print(f" ({elapsed():.3f}s)") |
| 421 | |
| 422 | raise SystemExit(exit_code) |
File History
4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2
fix: remove commit_exists filter from have anchors — server…
Sonnet 4.6
patch
21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a
fix: repair four test failures from post-migration audit
Sonnet 4.6
patch
29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf
fix: unified object store migration — idempotent writes, JS…
Sonnet 4.6
minor
⚠
29 days ago