gabriel / muse public
check.py python
422 lines 15.5 KB
Raw
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