cgcardona / muse public
validate.py python
214 lines 6.6 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse validate — check musical integrity of the working tree.
2
3 Runs a suite of integrity checks against the Muse working tree and reports
4 issues in a structured format. Designed as the pre-commit quality gate so
5 agents and producers can catch problems before ``muse commit`` records bad
6 state into history.
7
8 Checks performed
9 ----------------
10 - **midi_integrity** — every .mid/.midi file has a valid Standard MIDI File header.
11 - **manifest_consistency** — working tree matches the committed snapshot manifest.
12 - **no_duplicate_tracks** — no two MIDI files share the same instrument role.
13 - **section_naming** — section subdirectories follow ``[a-z][a-z0-9_-]*``.
14 - **emotion_tags** — emotion tags (if present) are from the allowed vocabulary.
15
16 Exit codes
17 ----------
18 - 0 — all checks passed (clean working tree)
19 - 1 — one or more ERROR issues found
20 - 2 — one or more WARN issues found and ``--strict`` was passed
21 - 3 — internal error (unexpected exception)
22
23 Output (default human-readable)::
24
25 Validating working tree …
26
27 ✅ midi_integrity PASS
28 ❌ manifest_consistency FAIL
29 ERROR beat.mid File in committed manifest is missing from working tree.
30 ✅ no_duplicate_tracks PASS
31 ⚠️ section_naming WARN
32 WARN Verse Section directory 'Verse' does not follow naming convention.
33 ✅ emotion_tags PASS
34
35 1 error, 1 warning — working tree has integrity issues.
36
37 Flags
38 -----
39 --strict Fail (exit 2) on warnings as well as errors.
40 --track TEXT Restrict checks to files/paths containing TEXT.
41 --section TEXT Restrict section-naming check to directories containing TEXT.
42 --fix Auto-fix correctable issues (quantisation, manifest).
43 --json Emit full results as JSON for agent consumption.
44 """
45 from __future__ import annotations
46
47 import json
48 import logging
49 from typing import Optional
50
51 import typer
52
53 from maestro.muse_cli._repo import require_repo
54 from maestro.muse_cli.errors import ExitCode
55 from maestro.services.muse_validate import (
56 MuseValidateResult,
57 ValidationSeverity,
58 run_validate,
59 )
60
61 logger = logging.getLogger(__name__)
62
63 app = typer.Typer()
64
65 # ---------------------------------------------------------------------------
66 # Rendering helpers
67 # ---------------------------------------------------------------------------
68
69 _SEVERITY_ICON: dict[str, str] = {
70 ValidationSeverity.ERROR: "❌",
71 ValidationSeverity.WARN: "⚠️ ",
72 ValidationSeverity.INFO: "ℹ️ ",
73 }
74
75
76 def _render_human(result: MuseValidateResult) -> None:
77 """Print a human-readable validate report to stdout."""
78 typer.echo("Validating working tree …")
79 typer.echo("")
80
81 for check in result.checks:
82 if check.passed:
83 icon = "✅"
84 label = "PASS"
85 elif any(i.severity == ValidationSeverity.ERROR for i in check.issues):
86 icon = "❌"
87 label = "FAIL"
88 else:
89 icon = "⚠️ "
90 label = "WARN"
91
92 typer.echo(f" {icon} {check.name:<28} {label}")
93 for issue in check.issues:
94 sev_icon = _SEVERITY_ICON.get(issue.severity, " ")
95 typer.echo(f" {sev_icon} {issue.severity.upper():<6} {issue.path}")
96 typer.echo(f" {issue.message}")
97
98 typer.echo("")
99
100 if result.fixes_applied:
101 typer.echo("Fixes applied:")
102 for fix in result.fixes_applied:
103 typer.echo(f" ✅ {fix}")
104 typer.echo("")
105
106 if result.clean:
107 typer.echo("✅ Working tree is clean — all checks passed.")
108 else:
109 errors = sum(
110 1
111 for c in result.checks
112 for i in c.issues
113 if i.severity == ValidationSeverity.ERROR
114 )
115 warnings = sum(
116 1
117 for c in result.checks
118 for i in c.issues
119 if i.severity == ValidationSeverity.WARN
120 )
121 parts: list[str] = []
122 if errors:
123 parts.append(f"{errors} error{'s' if errors != 1 else ''}")
124 if warnings:
125 parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
126 typer.echo(f"{'❌' if result.has_errors else '⚠️ '} {', '.join(parts)} — working tree has integrity issues.")
127
128
129 def _render_json(result: MuseValidateResult) -> None:
130 """Emit the validate result as a JSON object."""
131 typer.echo(json.dumps(result.to_dict(), indent=2))
132
133
134 # ---------------------------------------------------------------------------
135 # Exit code resolution
136 # ---------------------------------------------------------------------------
137
138 def _exit_code(result: MuseValidateResult, strict: bool) -> int:
139 """Map a MuseValidateResult to the appropriate CLI exit code.
140
141 Args:
142 result: The aggregated validation result.
143 strict: When True, warnings are treated as errors (exit 2).
144
145 Returns:
146 Integer exit code: 0=clean, 1=errors, 2=warnings-in-strict-mode.
147 """
148 if result.has_errors:
149 return ExitCode.USER_ERROR
150 if strict and result.has_warnings:
151 return 2
152 return ExitCode.SUCCESS
153
154
155 # ---------------------------------------------------------------------------
156 # Typer command
157 # ---------------------------------------------------------------------------
158
159
160 @app.callback(invoke_without_command=True)
161 def validate(
162 ctx: typer.Context,
163 strict: bool = typer.Option(
164 False,
165 "--strict",
166 help="Fail (exit 2) on warnings as well as errors.",
167 ),
168 track: Optional[str] = typer.Option(
169 None,
170 "--track",
171 help="Restrict checks to files/paths whose relative path contains TEXT.",
172 metavar="TEXT",
173 ),
174 section: Optional[str] = typer.Option(
175 None,
176 "--section",
177 help="Restrict section-naming check to directories containing TEXT.",
178 metavar="TEXT",
179 ),
180 fix: bool = typer.Option(
181 False,
182 "--fix",
183 help="Auto-fix correctable issues (e.g. re-quantize off-grid notes).",
184 ),
185 as_json: bool = typer.Option(
186 False,
187 "--json",
188 help="Emit full results as JSON for agent consumption.",
189 ),
190 ) -> None:
191 """Check musical integrity of the working tree before committing."""
192 root = require_repo()
193
194 try:
195 result = run_validate(
196 root,
197 strict=strict,
198 track_filter=track,
199 section_filter=section,
200 auto_fix=fix,
201 )
202 except Exception as exc:
203 typer.echo(f"❌ muse validate failed: {exc}")
204 logger.error("❌ muse validate error: %s", exc, exc_info=True)
205 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
206
207 if as_json:
208 _render_json(result)
209 else:
210 _render_human(result)
211
212 code = _exit_code(result, strict=strict)
213 if code != ExitCode.SUCCESS:
214 raise typer.Exit(code=code)