gabriel / muse public
invariants.py python
395 lines 13.1 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 days ago
1 """muse code invariants — enforce architectural rules from .muse/code_invariants.toml.
2
3 Loads declarative invariant rules and evaluates them against the committed
4 snapshot at HEAD (or any historical commit). Rules are architectural
5 constraints enforced by static analysis — no code is executed.
6
7 Rules file
8 ----------
9 Create ``.muse/code_invariants.toml`` using ``[[rule]]`` blocks:
10
11 .. code-block:: toml
12
13 [[rule]]
14 name = "complexity gate"
15 severity = "warning"
16 scope = "function"
17 rule_type = "max_complexity"
18 [rule.params]
19 threshold = 10
20
21 [[rule]]
22 name = "no import cycles"
23 severity = "error"
24 scope = "file"
25 rule_type = "no_circular_imports"
26
27 [[rule]]
28 name = "no dead exports"
29 severity = "warning"
30 scope = "file"
31 rule_type = "no_dead_exports"
32
33 [[rule]]
34 name = "test coverage floor"
35 severity = "warning"
36 scope = "repo"
37 rule_type = "test_coverage_floor"
38 [rule.params]
39 min_ratio = 0.30
40
41 [[rule]]
42 name = "core must not import cli"
43 severity = "error"
44 scope = "file"
45 rule_type = "forbidden_dependency"
46 [rule.params]
47 source_pattern = "muse/core/"
48 forbidden_pattern = "muse/cli/"
49
50 [[rule]]
51 name = "plugins must not import from cli"
52 severity = "error"
53 scope = "file"
54 rule_type = "layer_boundary"
55 [rule.params]
56 lower = "muse/plugins/"
57 upper = "muse/cli/"
58
59 Supported rule types
60 --------------------
61 ``max_complexity``
62 Functions whose branch-count exceeds *threshold*. Default threshold: 10.
63
64 ``no_circular_imports``
65 Import cycles among Python files in the snapshot.
66
67 ``no_dead_exports``
68 Top-level functions/classes never imported by any other file.
69 Exempt: test files, ``__init__.py``, files with ``__all__``.
70
71 ``test_coverage_floor``
72 Requires ≥ *min_ratio* of public functions to have a ``test_`` counterpart.
73
74 ``forbidden_dependency``
75 Files matching *source_pattern* must not import from *forbidden_pattern*.
76 Enforces hard layer boundaries.
77
78 ``layer_boundary``
79 *lower*-layer files must not import from *upper*-layer files.
80
81 When no rules file is found, three built-in defaults are applied:
82 ``complexity_gate`` (warning, threshold=10), ``no_cycles`` (error),
83 ``dead_exports`` (warning).
84
85 Usage::
86
87 muse code invariants
88 muse code invariants --commit HEAD~5
89 muse code invariants --commit feat/my-branch
90 muse code invariants --rule no_circular_imports
91 muse code invariants --strict
92 muse code invariants --json
93
94 Flags:
95
96 ``--commit, -c REF``
97 Check a historical snapshot (branch name, commit SHA, or ``HEAD~N``).
98
99 ``--rule NAME_OR_TYPE``
100 Run only rules whose name or rule_type matches this value.
101
102 ``--strict``
103 Exit non-zero if any warning-level violations are found.
104
105 ``--json``
106 Emit a machine-readable JSON object.
107 """
108
109 import argparse
110 import json
111 import logging
112 import pathlib
113 import sys
114
115 from muse.core.envelope import EnvelopeJson, make_envelope
116 from muse.core.errors import ExitCode
117 from muse.core.paths import code_invariants_path
118 from muse.core.repo import require_repo
119 from muse.core.timing import start_timer
120 from muse.core.refs import (
121 get_head_commit_id,
122 read_current_branch,
123 )
124 from muse.core.commits import resolve_commit_ref
125 from muse.core.snapshots import get_commit_snapshot_manifest
126 from muse.plugins.code._invariants import CodeViolation, load_invariant_rules, run_invariants
127 from muse.core.validation import sanitize_display
128
129 type _StrMap = dict[str, str]
130 type _ByRule = dict[str, list[str]]
131 logger = logging.getLogger(__name__)
132
133 class _InvariantsOutputJson(EnvelopeJson):
134 """JSON envelope emitted by ``muse code invariants --json``.
135
136 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
137
138 Fields
139 ------
140 commit Abbreviated commit ID of the snapshot analysed.
141 branch Current branch name.
142 ref Human-readable ref label (branch + short SHA).
143 using_defaults True when no .muse/code_invariants.toml was found.
144 rule_filter The --rule filter applied, or None.
145 strict True when --strict was passed.
146 rules_checked Number of rules evaluated.
147 violations_total Total number of violations across all rules.
148 errors Count of error-severity violations.
149 warnings_count Count of warning-severity violations.
150 violations List of violation dicts ({rule_name, address, description, severity}).
151 """
152
153 commit: str
154 branch: str
155 ref: str
156 using_defaults: bool
157 rule_filter: str | None
158 strict: bool
159 rules_checked: int
160 violations_total: int
161 errors: int
162 warnings_count: int
163 violations: list[CodeViolation]
164
165 class _InvariantsErrorJson(EnvelopeJson):
166 """JSON error output for ``muse code invariants --json`` on early-exit paths."""
167
168 error: str
169 message: str
170
171 # ---------------------------------------------------------------------------
172 # CLI registration
173 # ---------------------------------------------------------------------------
174
175 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
176 """Register the ``invariants`` subcommand."""
177 parser = subparsers.add_parser(
178 "invariants",
179 help="Check architectural invariants from .muse/code_invariants.toml.",
180 description=__doc__,
181 formatter_class=argparse.RawDescriptionHelpFormatter,
182 )
183 parser.add_argument(
184 "--commit", "-c",
185 dest="ref",
186 default=None,
187 metavar="REF",
188 help="Check a historical snapshot (branch, SHA, or HEAD~N).",
189 )
190 parser.add_argument(
191 "--rule",
192 dest="rule_filter",
193 default=None,
194 metavar="NAME_OR_TYPE",
195 help="Run only rules whose name or rule_type matches this value.",
196 )
197 parser.add_argument(
198 "--strict",
199 dest="strict",
200 action="store_true",
201 help="Exit non-zero if any warning-level violations are found.",
202 )
203 parser.add_argument(
204 "--json", "-j",
205 dest="json_out",
206 action="store_true",
207 help="Emit results as JSON.",
208 )
209 parser.set_defaults(func=run)
210
211 def run(args: argparse.Namespace) -> None:
212 """Check architectural invariants and exit non-zero on violations.
213
214 Evaluates rules from ``.muse/code_invariants.toml`` (or built-in defaults)
215 against the committed snapshot. Rule types: ``max_complexity``,
216 ``no_circular_imports``, ``no_dead_exports``, ``test_coverage_floor``,
217 ``forbidden_dependency``, ``layer_boundary``.
218
219 Agent quickstart
220 ----------------
221 ::
222
223 muse code check --json
224 muse code check --strict --json
225 muse code check --rule no_circular_imports --json
226 muse code check --ref HEAD~5 --json
227
228 JSON fields
229 -----------
230 commit Short commit ID checked.
231 branch Branch name.
232 using_defaults ``true`` if no ``.muse/code_invariants.toml`` exists.
233 rules_checked Number of rules evaluated.
234 violations_total Total number of violations found.
235 errors Error-severity violation count.
236 warnings_count Warning-severity violation count.
237 violations List of violation objects: ``rule``, ``message``, ``severity``.
238
239 Exit codes
240 ----------
241 0 All rules pass (or only warnings without ``--strict``).
242 1 Error violations found; or warnings found under ``--strict``.
243 2 Not inside a Muse repository.
244 """
245 elapsed = start_timer()
246 ref: str | None = args.ref
247 json_out: bool = args.json_out
248 rule_filter: str | None = getattr(args, "rule_filter", None)
249 strict: bool = getattr(args, "strict", False)
250
251 root = require_repo()
252 branch = read_current_branch(root)
253
254 # Resolve ref — support branch names in addition to SHAs and HEAD~N.
255 # Only attempt branch-name lookup when the ref looks like a plain name
256 # (no ~, ^, @, : or whitespace) — those characters signal relative ref
257 # syntax that validate_branch_name rejects but resolve_commit_ref handles.
258 resolved_ref: str | None = ref
259 if ref is not None and not any(c in ref for c in "~^@: \t"):
260 branch_head = get_head_commit_id(root, ref)
261 if branch_head is not None:
262 resolved_ref = branch_head
263
264 commit = resolve_commit_ref(root, branch, resolved_ref)
265 if commit is None:
266 print(f"❌ No commit found for ref '{ref or 'HEAD'}'.", file=sys.stderr)
267 raise SystemExit(ExitCode.USER_ERROR)
268
269 # Guard: refuse to proceed if the manifest is unreadable.
270 manifest = get_commit_snapshot_manifest(root, commit.commit_id)
271 if manifest is None:
272 print(
273 f"❌ Cannot read snapshot for commit {commit.commit_id} — "
274 "repository may be corrupt.",
275 file=sys.stderr,
276 )
277 raise SystemExit(ExitCode.INTERNAL_ERROR)
278
279 # Load rules — built-in defaults apply when the file is absent.
280 using_defaults = not code_invariants_path(root).exists()
281 rules = load_invariant_rules(root, None)
282
283 # Apply optional rule filter.
284 if rule_filter:
285 rules = [
286 r for r in rules
287 if rule_filter in (r.get("name", ""), r.get("rule_type", ""))
288 ]
289 if not rules:
290 msg = f"No rules match filter '{rule_filter}'."
291 if json_out:
292 print(json.dumps(_InvariantsErrorJson(
293 **make_envelope(elapsed),
294 error="no_matching_rules",
295 message=msg,
296 )))
297 else:
298 print(f"⚠️ {msg}", file=sys.stderr)
299 raise SystemExit(0)
300
301 if not rules:
302 msg = (
303 "No rules defined. "
304 "Create .muse/code_invariants.toml with [[rule]] blocks to define architectural constraints."
305 )
306 if json_out:
307 print(json.dumps(_InvariantsOutputJson(
308 **make_envelope(elapsed),
309 commit=commit.commit_id,
310 branch=branch,
311 ref=commit.commit_id,
312 using_defaults=False,
313 rule_filter=rule_filter,
314 strict=strict,
315 rules_checked=0,
316 violations_total=0,
317 errors=0,
318 warnings_count=0,
319 violations=[],
320 )))
321 else:
322 print(f"⚠️ {msg}")
323 raise SystemExit(0)
324
325 # Run all rules via the plugin engine.
326 report = run_invariants(root, commit.commit_id, rules)
327
328 violations = report["violations"]
329 errors = sum(1 for v in violations if v["severity"] == "error")
330 warnings = sum(1 for v in violations if v["severity"] == "warning")
331 exit_code = 1 if (errors > 0 or (strict and warnings > 0)) else 0
332
333 ref_label = commit.commit_id
334 if ref and ref != commit.commit_id:
335 ref_label = f"{ref} ({commit.commit_id})"
336
337 if json_out:
338 print(json.dumps(_InvariantsOutputJson(
339 **make_envelope(elapsed, exit_code=exit_code),
340 commit=commit.commit_id,
341 branch=branch,
342 ref=ref_label,
343 using_defaults=using_defaults,
344 rule_filter=rule_filter,
345 strict=strict,
346 rules_checked=report["rules_checked"],
347 violations_total=len(violations),
348 errors=errors,
349 warnings_count=warnings,
350 violations=list(violations),
351 )))
352 raise SystemExit(exit_code)
353
354 print(f"\nInvariant check — {ref_label}")
355 if using_defaults:
356 print(" (using built-in default rules — create .muse/code_invariants.toml to customise)")
357 if rule_filter:
358 print(f" (filter: {rule_filter})")
359 print("─" * 62)
360
361 if not violations:
362 print(f"\n ✅ All {report['rules_checked']} rule(s) passed.")
363 raise SystemExit(0)
364
365 # Group violations by rule name for clean output.
366 from collections import defaultdict
367 by_rule: _ByRule = defaultdict(list)
368 severity_by_rule: _StrMap = {}
369 for v in violations:
370 by_rule[v["rule_name"]].append(
371 f" {sanitize_display(v['address'])}: {sanitize_display(v['description'])}" if v["address"] != "repo"
372 else f" {sanitize_display(v['description'])}"
373 )
374 severity_by_rule[v["rule_name"]] = v["severity"]
375
376 # Show rules that passed (from original rules list) and rules that violated.
377 violated_names = set(by_rule)
378 passed_count = report["rules_checked"] - len(violated_names)
379
380 for rule in rules:
381 rule_name = rule.get("name", "unnamed")
382 if rule_name not in violated_names:
383 print(f"\n ✅ {sanitize_display(rule_name)}")
384 else:
385 sev = severity_by_rule[rule_name]
386 icon = "🔴" if sev == "error" else "⚠️ "
387 print(f"\n{icon} {sanitize_display(rule_name)} ({sev})")
388 for line in by_rule[rule_name][:5]:
389 print(f" {sanitize_display(line)}")
390 if len(by_rule[rule_name]) > 5:
391 print(f" … and {len(by_rule[rule_name]) - 5} more")
392
393 print(f"\n {passed_count} rule(s) passed · {len(violated_names)} rule(s) violated")
394 print(f" {errors} error(s) · {warnings} warning(s)")
395 raise SystemExit(exit_code)
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 23 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 33 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 35 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 49 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago