invariants.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 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 | from __future__ import annotations |
| 110 | |
| 111 | import argparse |
| 112 | import json |
| 113 | import logging |
| 114 | import pathlib |
| 115 | import sys |
| 116 | |
| 117 | from muse._version import __version__ |
| 118 | from muse.core.errors import ExitCode |
| 119 | from muse.core.repo import read_repo_id, require_repo |
| 120 | from muse.core.store import ( |
| 121 | get_commit_snapshot_manifest, |
| 122 | get_head_commit_id, |
| 123 | read_current_branch, |
| 124 | resolve_commit_ref, |
| 125 | ) |
| 126 | from muse.plugins.code._invariants import load_invariant_rules, run_invariants |
| 127 | from muse.core.validation import sanitize_display |
| 128 | |
| 129 | |
| 130 | |
| 131 | type _StrMap = dict[str, str] |
| 132 | type _ByRule = dict[str, list[str]] |
| 133 | logger = logging.getLogger(__name__) |
| 134 | |
| 135 | _RULES_FILE = pathlib.Path(".muse") / "code_invariants.toml" |
| 136 | |
| 137 | |
| 138 | # --------------------------------------------------------------------------- |
| 139 | # CLI plumbing |
| 140 | # --------------------------------------------------------------------------- |
| 141 | |
| 142 | |
| 143 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 144 | """Register the ``invariants`` subcommand.""" |
| 145 | parser = subparsers.add_parser( |
| 146 | "invariants", |
| 147 | help="Check architectural invariants from .muse/code_invariants.toml.", |
| 148 | description=__doc__, |
| 149 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 150 | ) |
| 151 | parser.add_argument( |
| 152 | "--commit", "-c", |
| 153 | dest="ref", |
| 154 | default=None, |
| 155 | metavar="REF", |
| 156 | help="Check a historical snapshot (branch, SHA, or HEAD~N).", |
| 157 | ) |
| 158 | parser.add_argument( |
| 159 | "--rule", |
| 160 | dest="rule_filter", |
| 161 | default=None, |
| 162 | metavar="NAME_OR_TYPE", |
| 163 | help="Run only rules whose name or rule_type matches this value.", |
| 164 | ) |
| 165 | parser.add_argument( |
| 166 | "--strict", |
| 167 | dest="strict", |
| 168 | action="store_true", |
| 169 | help="Exit non-zero if any warning-level violations are found.", |
| 170 | ) |
| 171 | parser.add_argument( |
| 172 | "--json", |
| 173 | dest="as_json", |
| 174 | action="store_true", |
| 175 | help="Emit results as JSON.", |
| 176 | ) |
| 177 | parser.set_defaults(func=run) |
| 178 | |
| 179 | |
| 180 | def run(args: argparse.Namespace) -> None: |
| 181 | """Check architectural invariants and exit non-zero on violations. |
| 182 | |
| 183 | Delegates rule evaluation to the code-domain plugin engine |
| 184 | (``muse.plugins.code._invariants``). All rule types are supported: |
| 185 | ``max_complexity``, ``no_circular_imports``, ``no_dead_exports``, |
| 186 | ``test_coverage_floor``, ``forbidden_dependency``, ``layer_boundary``. |
| 187 | |
| 188 | Exits with code 0 when all checked rules pass (or only warnings when |
| 189 | ``--strict`` is not set). Exits with code 1 when any error-severity |
| 190 | rule is violated, or when ``--strict`` is set and warnings are present. |
| 191 | """ |
| 192 | ref: str | None = args.ref |
| 193 | as_json: bool = args.as_json |
| 194 | rule_filter: str | None = getattr(args, "rule_filter", None) |
| 195 | strict: bool = getattr(args, "strict", False) |
| 196 | |
| 197 | root = require_repo() |
| 198 | repo_id = read_repo_id(root) |
| 199 | branch = read_current_branch(root) |
| 200 | |
| 201 | # Resolve ref — support branch names in addition to SHAs and HEAD~N. |
| 202 | resolved_ref: str | None = ref |
| 203 | if ref is not None: |
| 204 | branch_head = get_head_commit_id(root, ref) |
| 205 | if branch_head is not None: |
| 206 | resolved_ref = branch_head |
| 207 | |
| 208 | commit = resolve_commit_ref(root, repo_id, branch, resolved_ref) |
| 209 | if commit is None: |
| 210 | print(f"❌ No commit found for ref '{ref or 'HEAD'}'.", file=sys.stderr) |
| 211 | raise SystemExit(ExitCode.USER_ERROR) |
| 212 | |
| 213 | # Guard: refuse to proceed if the manifest is unreadable. |
| 214 | manifest = get_commit_snapshot_manifest(root, commit.commit_id) |
| 215 | if manifest is None: |
| 216 | print( |
| 217 | f"❌ Cannot read snapshot for commit {commit.commit_id[:8]} — " |
| 218 | "repository may be corrupt.", |
| 219 | file=sys.stderr, |
| 220 | ) |
| 221 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 222 | |
| 223 | # Load rules — built-in defaults apply when the file is absent. |
| 224 | rules_file = root / _RULES_FILE |
| 225 | rules = load_invariant_rules(rules_file if rules_file.exists() else None) |
| 226 | using_defaults = not rules_file.exists() |
| 227 | |
| 228 | # Apply optional rule filter. |
| 229 | if rule_filter: |
| 230 | rules = [ |
| 231 | r for r in rules |
| 232 | if rule_filter in (r.get("name", ""), r.get("rule_type", "")) |
| 233 | ] |
| 234 | if not rules: |
| 235 | msg = f"No rules match filter '{rule_filter}'." |
| 236 | if as_json: |
| 237 | print(json.dumps({ |
| 238 | "schema_version": __version__, |
| 239 | "error": "no_matching_rules", |
| 240 | "message": msg, |
| 241 | }, indent=2)) |
| 242 | else: |
| 243 | print(f"⚠️ {msg}", file=sys.stderr) |
| 244 | raise SystemExit(0) |
| 245 | |
| 246 | if not rules: |
| 247 | msg = ( |
| 248 | "No rules defined. " |
| 249 | f"Create {_RULES_FILE} with [[rule]] blocks to define architectural constraints." |
| 250 | ) |
| 251 | if as_json: |
| 252 | print(json.dumps({ |
| 253 | "schema_version": __version__, |
| 254 | "commit": commit.commit_id[:8], |
| 255 | "branch": branch, |
| 256 | "using_defaults": False, |
| 257 | "rules_checked": 0, |
| 258 | "errors": 0, |
| 259 | "warnings": 0, |
| 260 | "violations": [], |
| 261 | }, indent=2)) |
| 262 | else: |
| 263 | print(f"⚠️ {msg}") |
| 264 | raise SystemExit(0) |
| 265 | |
| 266 | # Run all rules via the plugin engine. |
| 267 | report = run_invariants(root, commit.commit_id, rules) |
| 268 | |
| 269 | violations = report["violations"] |
| 270 | errors = sum(1 for v in violations if v["severity"] == "error") |
| 271 | warnings = sum(1 for v in violations if v["severity"] == "warning") |
| 272 | exit_code = 1 if (errors > 0 or (strict and warnings > 0)) else 0 |
| 273 | |
| 274 | ref_label = commit.commit_id[:8] |
| 275 | if ref and ref != commit.commit_id: |
| 276 | ref_label = f"{ref} ({commit.commit_id[:8]})" |
| 277 | |
| 278 | if as_json: |
| 279 | print(json.dumps( |
| 280 | { |
| 281 | "schema_version": __version__, |
| 282 | "commit": commit.commit_id[:8], |
| 283 | "branch": branch, |
| 284 | "ref": ref_label, |
| 285 | "using_defaults": using_defaults, |
| 286 | "rule_filter": rule_filter, |
| 287 | "strict": strict, |
| 288 | "rules_checked": report["rules_checked"], |
| 289 | "violations_total": len(violations), |
| 290 | "errors": errors, |
| 291 | "warnings": warnings, |
| 292 | "violations": list(violations), |
| 293 | }, |
| 294 | indent=2, |
| 295 | )) |
| 296 | raise SystemExit(exit_code) |
| 297 | |
| 298 | print(f"\nInvariant check — {ref_label}") |
| 299 | if using_defaults: |
| 300 | print(" (using built-in default rules — create .muse/code_invariants.toml to customise)") |
| 301 | if rule_filter: |
| 302 | print(f" (filter: {rule_filter})") |
| 303 | print("─" * 62) |
| 304 | |
| 305 | if not violations: |
| 306 | print(f"\n ✅ All {report['rules_checked']} rule(s) passed.") |
| 307 | raise SystemExit(0) |
| 308 | |
| 309 | # Group violations by rule name for clean output. |
| 310 | from collections import defaultdict |
| 311 | by_rule: _ByRule = defaultdict(list) |
| 312 | severity_by_rule: _StrMap = {} |
| 313 | for v in violations: |
| 314 | by_rule[v["rule_name"]].append( |
| 315 | f" {sanitize_display(v['address'])}: {sanitize_display(v['description'])}" if v["address"] != "repo" |
| 316 | else f" {sanitize_display(v['description'])}" |
| 317 | ) |
| 318 | severity_by_rule[v["rule_name"]] = v["severity"] |
| 319 | |
| 320 | # Show rules that passed (from original rules list) and rules that violated. |
| 321 | violated_names = set(by_rule) |
| 322 | passed_count = report["rules_checked"] - len(violated_names) |
| 323 | |
| 324 | for rule in rules: |
| 325 | rule_name = rule.get("name", "unnamed") |
| 326 | if rule_name not in violated_names: |
| 327 | print(f"\n ✅ {sanitize_display(rule_name)}") |
| 328 | else: |
| 329 | sev = severity_by_rule[rule_name] |
| 330 | icon = "🔴" if sev == "error" else "⚠️ " |
| 331 | print(f"\n{icon} {sanitize_display(rule_name)} ({sev})") |
| 332 | for line in by_rule[rule_name][:5]: |
| 333 | print(f" {sanitize_display(line)}") |
| 334 | if len(by_rule[rule_name]) > 5: |
| 335 | print(f" … and {len(by_rule[rule_name]) - 5} more") |
| 336 | |
| 337 | print(f"\n {passed_count} rule(s) passed · {len(violated_names)} rule(s) violated") |
| 338 | print(f" {errors} error(s) · {warnings} warning(s)") |
| 339 | raise SystemExit(exit_code) |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago