gabriel / muse public
code_check.py python
297 lines 10.2 KB
Raw
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
1 """``muse code code-check`` — code invariant enforcement.
2
3 Evaluates semantic rules declared in ``.muse/code_invariants.toml`` against
4 the code snapshot of the specified commit and reports violations.
5
6 Built-in rule types (declared in TOML)::
7
8 [[rule]]
9 name = "complexity_gate"
10 severity = "error"
11 rule_type = "max_complexity"
12 [rule.params]
13 threshold = 10
14
15 [[rule]]
16 name = "no_cycles"
17 severity = "error"
18 rule_type = "no_circular_imports"
19
20 [[rule]]
21 name = "dead_exports"
22 severity = "warning"
23 rule_type = "no_dead_exports"
24
25 [[rule]]
26 name = "coverage_floor"
27 severity = "warning"
28 rule_type = "test_coverage_floor"
29 [rule.params]
30 min_ratio = 0.30
31
32 Usage::
33
34 muse code code-check # check HEAD
35 muse code code-check abc1234 # check specific commit
36 muse code code-check --strict # exit 1 on any error-severity violation
37 muse code code-check --json # machine-readable JSON output
38 muse code code-check --rules my_rules.toml # custom rules file
39 muse code code-check --filter error # show only error-severity violations
40 muse code code-check --diff HEAD~1 # show only NEW violations vs reference
41 muse code code-check --diff main --strict # CI ratchet: fail only on regressions
42 """
43
44 import argparse
45 import json
46 import logging
47 import pathlib
48 import sys
49 from typing import TypedDict
50
51 from muse.core.types import long_id
52 from muse.core.invariants import diff_reports, format_report, make_report
53 from muse.core.repo import require_repo
54 from muse.core.refs import (
55 get_head_commit_id,
56 read_current_branch,
57 )
58 from muse.core.commits import find_commits_by_prefix
59 from muse.core.snapshots import get_commit_snapshot_manifest
60 from muse.core.timing import start_timer
61 from muse.core.envelope import EnvelopeJson, make_envelope
62 from muse.core.validation import contain_path
63 from muse.plugins.code._invariants import load_invariant_rules, run_invariants
64
65 logger = logging.getLogger(__name__)
66
67 _SEVERITIES = ("error", "warning", "info")
68
69 class _ViolationDict(TypedDict):
70 rule_name: str
71 severity: str
72 address: str
73 description: str
74
75 class _CodeCheckOutputJson(EnvelopeJson, total=False):
76 """Top-level JSON envelope emitted by ``muse code code-check --json``.
77
78 Fields
79 ------
80 commit_id Full commit ID that was checked.
81 domain Domain tag (always ``"code"``).
82 violations List of violation dicts (rule_name, severity, address, description).
83 rules_checked Number of rules that were evaluated.
84 has_errors True when any violation has severity ``"error"``.
85 has_warnings True when any violation has severity ``"warning"``.
86 diff_ref The --diff reference commit, if provided; else absent.
87 severity_filter The --filter severity, if provided; else absent.
88 """
89
90 commit_id: str
91 domain: str
92 violations: list[_ViolationDict]
93 rules_checked: int
94 has_errors: bool
95 has_warnings: bool
96 diff_ref: str
97 severity_filter: str
98
99 def _resolve_commit(root: pathlib.Path, ref: str | None) -> str | None:
100 """Resolve *ref* to a full commit ID.
101
102 Resolution order:
103 1. ``None`` → HEAD of the current branch.
104 2. Branch name that exists in the store → HEAD commit of that branch.
105 3. Anything else → returned as-is (assumed to be a full or partial
106 commit ID; the caller validates via the snapshot manifest).
107 """
108 if ref is None:
109 branch = read_current_branch(root)
110 return get_head_commit_id(root, branch)
111 # If ref looks like a commit ID (sha256: prefix or raw hex), resolve it.
112 bare = long_id(ref, strip=True)
113 if all(c in "0123456789abcdefABCDEF" for c in bare) and 1 <= len(bare) <= 64:
114 if len(bare) == 64:
115 return ref # full ID — pass as-is
116 # Short prefix — expand to full commit ID via prefix lookup.
117 matches = find_commits_by_prefix(root, bare)
118 if matches:
119 return matches[0].commit_id
120 return ref # not found; let caller surface the error
121 # Try treating ref as a branch name first.
122 try:
123 branch_commit = get_head_commit_id(root, ref)
124 except (ValueError, KeyError):
125 branch_commit = None
126 if branch_commit is not None:
127 return branch_commit
128 # Fall back: treat as a literal commit ID.
129 return ref
130
131 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
132 """Register the code-check subcommand."""
133 parser = subparsers.add_parser(
134 "code-check",
135 help="Enforce code invariant rules against a commit snapshot.",
136 description=__doc__,
137 formatter_class=argparse.RawDescriptionHelpFormatter,
138 )
139 parser.add_argument(
140 "commit_arg", nargs="?", default=None,
141 metavar="commit",
142 help="Commit ID to check (default: HEAD).",
143 )
144 parser.add_argument(
145 "--strict", action="store_true",
146 help="Exit 1 when any error-severity violation is found.",
147 )
148 parser.add_argument(
149 "--json", "-j", action="store_true", dest="json_out",
150 help="Emit machine-readable JSON.",
151 )
152 parser.add_argument(
153 "--rules", default=None, dest="rules_file",
154 metavar="RULES_FILE",
155 help=(
156 "Path to a TOML invariants file inside the repo "
157 "(default: .muse/code_invariants.toml)."
158 ),
159 )
160 parser.add_argument(
161 "--filter",
162 dest="severity_filter",
163 choices=_SEVERITIES,
164 default=None,
165 metavar="SEVERITY",
166 help="Show only violations of this severity (error | warning | info).",
167 )
168 parser.add_argument(
169 "--diff",
170 dest="diff_ref",
171 default=None,
172 metavar="REF",
173 help=(
174 "Show only violations that are NEW compared to REF. "
175 "Ideal for CI: muse code code-check --diff HEAD~1 --strict "
176 "fails only when you introduce a new violation."
177 ),
178 )
179 parser.set_defaults(func=run)
180
181 def run(args: argparse.Namespace) -> None:
182 """Enforce code invariant rules against a commit snapshot.
183
184 Reports cyclomatic complexity violations, import cycles, dead exports, and
185 test coverage shortfalls based on rules in ``.muse/code_invariants.toml``
186 (or built-in defaults when the file is absent). Use ``--diff`` to report
187 only regressions relative to a baseline commit.
188
189 Agent quickstart
190 ----------------
191 ::
192
193 muse code check --json
194 muse code check --strict --json
195 muse code check --diff HEAD~1 --json
196
197 JSON fields
198 -----------
199 commit_id Full commit ID checked.
200 has_errors ``true`` if any error-severity violations were found.
201 violation_count Total number of violations.
202 violations List of violation objects: ``rule``, ``path``, ``message``,
203 ``severity``.
204 exit_code 0 = clean (or only warnings without ``--strict``);
205 1 = errors (or warnings with ``--strict``).
206
207 Exit codes
208 ----------
209 0 No violations (or only warnings without ``--strict``).
210 1 Errors found; or warnings found with ``--strict``.
211 2 Not inside a Muse repository.
212 """
213 elapsed = start_timer()
214 commit_arg: str | None = args.commit_arg
215 strict: bool = args.strict
216 json_out: bool = args.json_out
217 rules_file: str | None = args.rules_file
218 severity_filter: str | None = args.severity_filter
219 diff_ref: str | None = args.diff_ref
220
221 root = require_repo()
222
223 commit_id = _resolve_commit(root, commit_arg)
224 if commit_id is None:
225 print("❌ No commit found.", file=sys.stderr)
226 raise SystemExit(1)
227
228 rules_path: pathlib.Path | None = None
229 if rules_file:
230 try:
231 rules_path = contain_path(root, rules_file)
232 except ValueError as exc:
233 print(f"❌ {exc}", file=sys.stderr)
234 raise SystemExit(1)
235 if not rules_path.exists():
236 print(f"❌ Rules file not found: {rules_path}", file=sys.stderr)
237 raise SystemExit(1)
238
239 rules = load_invariant_rules(root, rules_path)
240 report = run_invariants(root, commit_id, rules)
241
242 # --diff: subtract violations already present in the reference commit so
243 # only regressions are reported.
244 if diff_ref is not None:
245 ref_id = _resolve_commit(root, diff_ref)
246 if ref_id is None:
247 print(f"❌ Could not resolve --diff ref: {diff_ref!r}", file=sys.stderr)
248 raise SystemExit(1)
249 if get_commit_snapshot_manifest(root, ref_id) is None:
250 print(
251 f"❌ Commit {diff_ref!r} not found — cannot use as --diff base.",
252 file=sys.stderr,
253 )
254 raise SystemExit(1)
255 ref_report = run_invariants(root, ref_id, rules)
256 report = diff_reports(report, ref_report)
257
258 # --filter: drop violations that don't match the requested severity.
259 if severity_filter is not None:
260 filtered = [v for v in report["violations"] if v["severity"] == severity_filter]
261 report = make_report(
262 report["commit_id"],
263 report["domain"],
264 filtered,
265 report["rules_checked"],
266 )
267
268 if json_out:
269 _exit_code = 1 if (strict and report["has_errors"]) else 0
270 out = _CodeCheckOutputJson(
271 **make_envelope(elapsed, exit_code=_exit_code),
272 commit_id=report["commit_id"],
273 domain=report["domain"],
274 violations=report["violations"],
275 rules_checked=report["rules_checked"],
276 has_errors=report["has_errors"],
277 has_warnings=report["has_warnings"],
278 )
279 if diff_ref is not None:
280 out["diff_ref"] = diff_ref
281 if severity_filter is not None:
282 out["severity_filter"] = severity_filter
283 print(json.dumps(out))
284 if _exit_code:
285 raise SystemExit(_exit_code)
286 return
287
288 diff_label = f" (new since {diff_ref})" if diff_ref else ""
289 filter_label = f" [{severity_filter} only]" if severity_filter else ""
290 print(
291 f"\ncode-check {commit_id}{diff_label}{filter_label}"
292 f" — {report['rules_checked']} rules"
293 )
294 print(format_report(report))
295
296 if strict and report["has_errors"]:
297 raise SystemExit(1)
File History 6 commits
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 30 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 30 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 33 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 52 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 104 days ago