attributes.py
python
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217
chore(timeline): remove unused RationalRate import in entity.py
Human
minor
⚠ breaking
8 days ago
| 1 | """muse attributes — query and display ``.museattributes`` merge-strategy rules. |
| 2 | |
| 3 | Reads, validates, and pretty-prints the ``.museattributes`` file from the |
| 4 | current repository. Supports tabular display, path-strategy resolution, and |
| 5 | file validation via three subcommands. |
| 6 | |
| 7 | Subcommands |
| 8 | ----------- |
| 9 | |
| 10 | ``muse attributes list`` |
| 11 | Pretty-print all rules in a table showing path pattern, dimension, |
| 12 | strategy, priority, and comment. Optionally filter by ``--strategy`` |
| 13 | or ``--dimension``. This is the **default** when no subcommand is given. |
| 14 | |
| 15 | ``muse attributes check PATH [PATH ...]`` |
| 16 | Resolve the merge strategy for one or more workspace-relative paths, |
| 17 | optionally filtered by dimension. Use ``--match-required`` to exit 1 |
| 18 | when any path falls back to the default ``auto`` strategy. |
| 19 | |
| 20 | ``muse attributes validate`` |
| 21 | Validate the ``.museattributes`` file for TOML syntax errors, missing |
| 22 | required fields, and unknown strategy values. |
| 23 | |
| 24 | Security model |
| 25 | -------------- |
| 26 | - All user-controlled values read from ``.museattributes`` (domain, path |
| 27 | patterns, dimensions, strategies, comments) are passed through |
| 28 | ``sanitize_display()`` before appearing in human-readable output. |
| 29 | - TOML parse errors and strategy validation errors are reported to **stderr** |
| 30 | without printing a raw traceback. |
| 31 | - ``_parse_raw`` enforces a 1 MiB file-size cap, preventing OOM from a |
| 32 | crafted or corrupted file. |
| 33 | - Null bytes in paths supplied to ``check`` are rejected with a clear error. |
| 34 | |
| 35 | Agent UX |
| 36 | -------- |
| 37 | Every subcommand accepts ``--json`` for machine-readable output on **stdout**. |
| 38 | All diagnostic and error messages go to **stderr** so that JSON consumers |
| 39 | never see noise on stdout. |
| 40 | |
| 41 | JSON schemas |
| 42 | ------------ |
| 43 | |
| 44 | ``muse attributes list --json``:: |
| 45 | |
| 46 | { |
| 47 | "domain": "midi", // always present; empty string when unset |
| 48 | "rule_count": 1, // total rules after filtering |
| 49 | "rules": [ |
| 50 | { |
| 51 | "path_pattern": "drums/*", |
| 52 | "dimension": "*", |
| 53 | "strategy": "ours", |
| 54 | "comment": "Drums are always authored by branch A.", |
| 55 | "priority": 10, |
| 56 | "source_index": 0 |
| 57 | } |
| 58 | ] |
| 59 | } |
| 60 | |
| 61 | ``muse attributes check --json``:: |
| 62 | |
| 63 | { |
| 64 | "results": [ |
| 65 | { |
| 66 | "path": "drums/kick.mid", |
| 67 | "dimension": "*", |
| 68 | "strategy": "ours", |
| 69 | "rule_index": 0 |
| 70 | } |
| 71 | ] |
| 72 | } |
| 73 | |
| 74 | ``muse attributes validate --json``:: |
| 75 | |
| 76 | { |
| 77 | "valid": true, |
| 78 | "rule_count": 3, |
| 79 | "errors": [] |
| 80 | } |
| 81 | |
| 82 | Exit codes |
| 83 | ---------- |
| 84 | - 0 — success |
| 85 | - 1 — user error (bad args, invalid strategy, TOML parse error, invalid path) |
| 86 | - 2 — not inside a Muse repository |
| 87 | """ |
| 88 | |
| 89 | import argparse |
| 90 | import fnmatch |
| 91 | import json |
| 92 | import sys |
| 93 | from typing import TYPE_CHECKING, TypedDict |
| 94 | |
| 95 | from muse.core.attributes import ( |
| 96 | AttributeRule, |
| 97 | AttributesMeta, |
| 98 | load_attributes_full, |
| 99 | ) |
| 100 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 101 | from muse.core.errors import ExitCode |
| 102 | from muse.core.repo import require_repo |
| 103 | from muse.core.timing import start_timer |
| 104 | from muse.core.validation import sanitize_display |
| 105 | |
| 106 | # --------------------------------------------------------------------------- |
| 107 | # JSON TypedDicts — stable, machine-readable output schemas |
| 108 | # --------------------------------------------------------------------------- |
| 109 | |
| 110 | class _RuleJson(TypedDict): |
| 111 | """JSON representation of a single ``.museattributes`` rule.""" |
| 112 | |
| 113 | path_pattern: str |
| 114 | dimension: str |
| 115 | strategy: str |
| 116 | comment: str |
| 117 | priority: int |
| 118 | source_index: int |
| 119 | |
| 120 | class _ListJson(EnvelopeJson): |
| 121 | """JSON output of ``muse attributes list``. |
| 122 | |
| 123 | ``rule_count`` reflects the number of rules after any ``--strategy`` or |
| 124 | ``--dimension`` filters have been applied — it equals ``len(rules)``. |
| 125 | """ |
| 126 | |
| 127 | domain: str |
| 128 | rule_count: int |
| 129 | rules: list[_RuleJson] |
| 130 | |
| 131 | class _CheckResultJson(TypedDict): |
| 132 | """JSON result for a single path resolution.""" |
| 133 | |
| 134 | path: str |
| 135 | dimension: str |
| 136 | strategy: str |
| 137 | rule_index: int # -1 when no rule matched (default "auto") |
| 138 | |
| 139 | class _CheckJson(EnvelopeJson): |
| 140 | """JSON output of ``muse attributes check``.""" |
| 141 | |
| 142 | results: list[_CheckResultJson] |
| 143 | |
| 144 | class _ValidateErrorJson(TypedDict): |
| 145 | """A single validation error entry.""" |
| 146 | |
| 147 | kind: str # "syntax" | "semantic" | "missing" |
| 148 | message: str |
| 149 | |
| 150 | class _ValidateJson(EnvelopeJson): |
| 151 | """JSON output of ``muse attributes validate``. |
| 152 | |
| 153 | ``rule_count`` is the number of rules successfully parsed. It is 0 when |
| 154 | ``valid`` is ``false`` (parse failed before rules could be counted). |
| 155 | """ |
| 156 | |
| 157 | valid: bool |
| 158 | rule_count: int |
| 159 | errors: list[_ValidateErrorJson] |
| 160 | |
| 161 | # --------------------------------------------------------------------------- |
| 162 | # Internal helpers |
| 163 | # --------------------------------------------------------------------------- |
| 164 | |
| 165 | def _resolve_with_index( |
| 166 | rules: list[AttributeRule], |
| 167 | path: str, |
| 168 | dimension: str, |
| 169 | ) -> tuple[str, int]: |
| 170 | """Return ``(strategy, rule.source_index)`` for *path* / *dimension*. |
| 171 | |
| 172 | Implements the same first-match semantics as :func:`resolve_strategy` but |
| 173 | also returns which rule was matched so callers can explain the decision. |
| 174 | Returns ``("auto", -1)`` when no rule matches. |
| 175 | |
| 176 | Args: |
| 177 | rules: Priority-sorted rule list from :func:`load_attributes_full`. |
| 178 | path: Workspace-relative POSIX path. |
| 179 | dimension: Domain axis name or ``"*"`` to match any dimension. |
| 180 | """ |
| 181 | for rule in rules: |
| 182 | path_match = fnmatch.fnmatch(path, rule.path_pattern) |
| 183 | dim_match = ( |
| 184 | rule.dimension == "*" |
| 185 | or rule.dimension == dimension |
| 186 | or dimension == "*" |
| 187 | ) |
| 188 | if path_match and dim_match: |
| 189 | return rule.strategy, rule.source_index |
| 190 | return "auto", -1 |
| 191 | |
| 192 | def _rule_to_json(rule: AttributeRule) -> _RuleJson: |
| 193 | """Convert an :class:`AttributeRule` to its JSON TypedDict form.""" |
| 194 | return _RuleJson( |
| 195 | path_pattern=rule.path_pattern, |
| 196 | dimension=rule.dimension, |
| 197 | strategy=rule.strategy, |
| 198 | comment=rule.comment, |
| 199 | priority=rule.priority, |
| 200 | source_index=rule.source_index, |
| 201 | ) |
| 202 | |
| 203 | # --------------------------------------------------------------------------- |
| 204 | # Command registration |
| 205 | # --------------------------------------------------------------------------- |
| 206 | |
| 207 | def register( |
| 208 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 209 | ) -> None: |
| 210 | """Register the ``attributes`` subcommand tree and all its flags. |
| 211 | |
| 212 | Every subcommand accepts ``--json`` for machine-readable output on stdout. |
| 213 | All diagnostic messages go to stderr. |
| 214 | """ |
| 215 | parser = subparsers.add_parser( |
| 216 | "attributes", |
| 217 | help="Query and display .museattributes merge-strategy rules.", |
| 218 | description=__doc__, |
| 219 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 220 | ) |
| 221 | subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") |
| 222 | subs.required = True |
| 223 | |
| 224 | # ── check ───────────────────────────────────────────────────────────── |
| 225 | check_p = subs.add_parser( |
| 226 | "check", |
| 227 | help="Resolve the merge strategy for one or more workspace-relative paths.", |
| 228 | description=( |
| 229 | "Apply ``.museattributes`` rule matching to one or more paths and\n" |
| 230 | "print the resolved strategy for each. Uses first-match semantics:\n" |
| 231 | "rules are evaluated in descending priority order; the first rule\n" |
| 232 | "whose path glob and dimension both match wins.\n\n" |
| 233 | "``rule_index`` in the output identifies which ``[[rules]]`` entry\n" |
| 234 | "matched (0-based source order); ``-1`` means no rule matched and\n" |
| 235 | "the default ``auto`` strategy applies. Use ``-d`` / ``--dimension``\n" |
| 236 | "to filter by a specific domain axis.\n\n" |
| 237 | "Agent quickstart\n" |
| 238 | "----------------\n" |
| 239 | " muse attributes check src/foo.py --json\n" |
| 240 | " muse attributes check src/foo.py -j\n" |
| 241 | " muse attributes check a.mid b.mid -d pitch_bend --json\n" |
| 242 | " muse attributes check src/foo.py --match-required # exit 1 if no rule matched\n\n" |
| 243 | "JSON output schema\n" |
| 244 | "------------------\n" |
| 245 | ' {"results": [{"path": "<str>", "dimension": "<str>",\n' |
| 246 | ' "strategy": "<str>", "rule_index": <int>}, ...]}\n\n' |
| 247 | "Exit codes\n" |
| 248 | "----------\n" |
| 249 | " 0 — all paths resolved (no-match defaults are still success)\n" |
| 250 | " 1 — null byte in a path arg, TOML parse error, or unknown strategy\n" |
| 251 | " 2 — not inside a Muse repository\n" |
| 252 | ), |
| 253 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 254 | ) |
| 255 | check_p.add_argument( |
| 256 | "paths", |
| 257 | nargs="+", |
| 258 | metavar="PATH", |
| 259 | help="Workspace-relative path(s) to resolve.", |
| 260 | ) |
| 261 | check_p.add_argument( |
| 262 | "--dimension", |
| 263 | "-d", |
| 264 | default="*", |
| 265 | metavar="DIM", |
| 266 | help="Domain dimension to match against (default: '*' matches any).", |
| 267 | ) |
| 268 | check_p.add_argument( |
| 269 | "--match-required", |
| 270 | action="store_true", |
| 271 | dest="match_required", |
| 272 | default=False, |
| 273 | help=( |
| 274 | "Exit 1 if any path resolves to the default 'auto' strategy " |
| 275 | "(i.e. no rule matched). Useful in CI or agent scripts that " |
| 276 | "require every path to have an explicit rule." |
| 277 | ), |
| 278 | ) |
| 279 | check_p.add_argument( |
| 280 | "--json", "-j", |
| 281 | action="store_true", |
| 282 | dest="json_out", |
| 283 | default=False, |
| 284 | help="Emit a JSON object with a 'results' array to stdout.", |
| 285 | ) |
| 286 | check_p.set_defaults(func=run_check) |
| 287 | |
| 288 | # ── list ────────────────────────────────────────────────────────────── |
| 289 | list_p = subs.add_parser( |
| 290 | "list", |
| 291 | help="Pretty-print all rules (path pattern, dimension, strategy, priority, comment).", |
| 292 | description=( |
| 293 | "Parse ``.museattributes`` and display every rule as an aligned\n" |
| 294 | "table showing path pattern, dimension, strategy, priority, and\n" |
| 295 | "comment. An optional priority column appears only when at least\n" |
| 296 | "one rule has a non-zero priority. A comment column appears only\n" |
| 297 | "when at least one rule carries a comment.\n\n" |
| 298 | "Agent quickstart\n" |
| 299 | "----------------\n" |
| 300 | " muse attributes list --json\n" |
| 301 | " muse attributes list -j\n" |
| 302 | " muse attributes list --strategy ours --json\n" |
| 303 | " muse attributes list --dimension notes --json\n" |
| 304 | " DOMAIN=$(muse attributes list --json | jq -r .domain)\n\n" |
| 305 | "JSON output schema\n" |
| 306 | "------------------\n" |
| 307 | ' {"domain": "<str>", "rule_count": <int>,\n' |
| 308 | ' "rules": [{"path_pattern": "<glob>", "dimension": "<str>",\n' |
| 309 | ' "strategy": "<str>", "comment": "<str>",\n' |
| 310 | ' "priority": <int>, "source_index": <int>}, ...]}\n\n' |
| 311 | "Exit codes\n" |
| 312 | "----------\n" |
| 313 | " 0 — success (empty rules list is still success)\n" |
| 314 | " 1 — TOML parse error or unknown strategy\n" |
| 315 | " 2 — not inside a Muse repository\n" |
| 316 | ), |
| 317 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 318 | ) |
| 319 | list_p.add_argument( |
| 320 | "--strategy", "-s", |
| 321 | default=None, |
| 322 | metavar="STRATEGY", |
| 323 | help="Filter output to rules with this strategy value (e.g. ours, theirs).", |
| 324 | ) |
| 325 | list_p.add_argument( |
| 326 | "--dimension", "-d", |
| 327 | default=None, |
| 328 | metavar="DIM", |
| 329 | help="Filter output to rules with this dimension value (e.g. notes, *).", |
| 330 | ) |
| 331 | list_p.add_argument( |
| 332 | "--json", "-j", |
| 333 | action="store_true", |
| 334 | dest="json_out", |
| 335 | default=False, |
| 336 | help="Emit a JSON object to stdout with domain, rule_count, and rules array.", |
| 337 | ) |
| 338 | list_p.set_defaults(func=run_list) |
| 339 | |
| 340 | # ── validate ────────────────────────────────────────────────────────── |
| 341 | validate_p = subs.add_parser( |
| 342 | "validate", |
| 343 | help="Validate .museattributes for TOML syntax and semantic correctness.", |
| 344 | description=( |
| 345 | "Parse ``.museattributes`` and check for TOML syntax errors,\n" |
| 346 | "missing required rule fields, unknown strategy values, and the\n" |
| 347 | "1 MiB file-size limit. Exits 0 only when the file is fully\n" |
| 348 | "valid; exits 1 for any validation failure.\n\n" |
| 349 | "In text mode, prints a one-line summary with the rule count and\n" |
| 350 | "domain on success, or an error message on failure.\n\n" |
| 351 | "Agent quickstart\n" |
| 352 | "----------------\n" |
| 353 | " muse attributes validate --json\n" |
| 354 | " muse attributes validate -j\n" |
| 355 | " muse attributes validate --json | jq .valid\n" |
| 356 | " muse attributes validate --json | jq '.errors[].kind'\n\n" |
| 357 | "JSON output schema\n" |
| 358 | "------------------\n" |
| 359 | ' {"valid": <bool>,\n' |
| 360 | ' "errors": [{"kind": "missing"|"syntax"|"semantic",\n' |
| 361 | ' "message": "<str>"}, ...]}\n\n' |
| 362 | "Error kinds\n" |
| 363 | "-----------\n" |
| 364 | ' "missing" — .museattributes does not exist\n' |
| 365 | ' "semantic" — TOML parsed but a rule field is invalid\n\n' |
| 366 | "Exit codes\n" |
| 367 | "----------\n" |
| 368 | " 0 — file is valid\n" |
| 369 | " 1 — file is missing, malformed, or contains unknown strategies\n" |
| 370 | " 2 — not inside a Muse repository\n" |
| 371 | ), |
| 372 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 373 | ) |
| 374 | validate_p.add_argument( |
| 375 | "--json", "-j", |
| 376 | action="store_true", |
| 377 | dest="json_out", |
| 378 | default=False, |
| 379 | help="Emit a JSON object with 'valid' and 'errors' fields to stdout.", |
| 380 | ) |
| 381 | validate_p.set_defaults(func=run_validate) |
| 382 | |
| 383 | # --------------------------------------------------------------------------- |
| 384 | # Subcommand handlers |
| 385 | # --------------------------------------------------------------------------- |
| 386 | |
| 387 | def run_list(args: argparse.Namespace) -> None: |
| 388 | """Display all ``.museattributes`` rules in tabular or JSON format. |
| 389 | |
| 390 | Parses the file, applies optional ``--strategy`` and ``--dimension`` filters, |
| 391 | then prints an aligned table or JSON object. An empty result after filtering |
| 392 | is still exit 0. All user-controlled values are sanitized before text output; |
| 393 | raw values are preserved verbatim in JSON. |
| 394 | |
| 395 | Agent quickstart |
| 396 | ---------------- |
| 397 | :: |
| 398 | |
| 399 | muse attributes list --json |
| 400 | muse attributes list --strategy ours --json |
| 401 | muse attributes list --dimension notes --json |
| 402 | |
| 403 | JSON fields |
| 404 | ----------- |
| 405 | domain Repository domain from the ``[meta]`` section; empty string if unset. |
| 406 | rule_count Total rules after filtering — equals ``len(rules)``. |
| 407 | rules List of rule objects sorted by descending priority. |
| 408 | |
| 409 | Each rule: |
| 410 | |
| 411 | path_pattern Glob matched against workspace-relative POSIX paths. |
| 412 | dimension Domain axis name, or ``"*"`` for any. |
| 413 | strategy Merge strategy (e.g. ``"ours"``, ``"theirs"``, ``"auto"``). |
| 414 | comment Optional annotation from the file. |
| 415 | priority Integer priority; higher values take precedence. |
| 416 | source_index Zero-based declaration order in the file. |
| 417 | |
| 418 | Exit codes |
| 419 | ---------- |
| 420 | 0 Success (empty rule list is still success). |
| 421 | 1 TOML parse error or unknown strategy. |
| 422 | 2 Not inside a Muse repository. |
| 423 | """ |
| 424 | elapsed = start_timer() |
| 425 | json_out: bool = args.json_out |
| 426 | filter_strategy: str | None = args.strategy |
| 427 | filter_dimension: str | None = args.dimension |
| 428 | |
| 429 | root = require_repo() |
| 430 | try: |
| 431 | meta, rules = load_attributes_full(root) |
| 432 | except ValueError as exc: |
| 433 | print(f"❌ {exc}", file=sys.stderr) |
| 434 | raise SystemExit(ExitCode.USER_ERROR.value) from exc |
| 435 | |
| 436 | # Apply optional filters. |
| 437 | if filter_strategy is not None: |
| 438 | rules = [r for r in rules if r.strategy == filter_strategy] |
| 439 | if filter_dimension is not None: |
| 440 | rules = [r for r in rules if r.dimension == filter_dimension] |
| 441 | |
| 442 | domain_raw: str = meta.get("domain") or "" |
| 443 | |
| 444 | if json_out: |
| 445 | print(json.dumps(_ListJson( |
| 446 | **make_envelope(elapsed), |
| 447 | domain=domain_raw, |
| 448 | rule_count=len(rules), |
| 449 | rules=[_rule_to_json(r) for r in rules], |
| 450 | ))) |
| 451 | return |
| 452 | |
| 453 | if not rules: |
| 454 | attr_file = root / ".museattributes" |
| 455 | if not attr_file.exists(): |
| 456 | print( |
| 457 | "No .museattributes file found.", |
| 458 | file=sys.stderr, |
| 459 | ) |
| 460 | else: |
| 461 | print( |
| 462 | ".museattributes is present but contains no rules.", |
| 463 | file=sys.stderr, |
| 464 | ) |
| 465 | print( |
| 466 | "Create one at the repository root to declare per-path merge strategies.", |
| 467 | file=sys.stderr, |
| 468 | ) |
| 469 | return |
| 470 | |
| 471 | if domain_raw: |
| 472 | print(f"Domain: {sanitize_display(domain_raw)}") |
| 473 | print() |
| 474 | |
| 475 | has_comments = any(r.comment for r in rules) |
| 476 | has_nonzero_priority = any(r.priority != 0 for r in rules) |
| 477 | |
| 478 | pat_w = max(len("Path pattern"), max(len(r.path_pattern) for r in rules)) |
| 479 | dim_w = max(len("Dimension"), max(len(r.dimension) for r in rules)) |
| 480 | pri_w = max(len("Pri"), max(len(str(r.priority)) for r in rules)) |
| 481 | |
| 482 | header_parts = [ |
| 483 | f"{'Path pattern':<{pat_w}}", |
| 484 | f"{'Dimension':<{dim_w}}", |
| 485 | f"{'Pri':>{pri_w}}", |
| 486 | "Strategy", |
| 487 | ] |
| 488 | sep_parts = ["-" * pat_w, "-" * dim_w, "-" * pri_w, "--------"] |
| 489 | if has_comments: |
| 490 | header_parts.append("Comment") |
| 491 | sep_parts.append("-------") |
| 492 | |
| 493 | print(" ".join(header_parts)) |
| 494 | print(" ".join(sep_parts)) |
| 495 | |
| 496 | for rule in rules: |
| 497 | pat = sanitize_display(rule.path_pattern) |
| 498 | dim = sanitize_display(rule.dimension) |
| 499 | strat = sanitize_display(rule.strategy) |
| 500 | pri_str = str(rule.priority) if has_nonzero_priority else "" |
| 501 | line_parts = [ |
| 502 | f"{pat:<{pat_w}}", |
| 503 | f"{dim:<{dim_w}}", |
| 504 | f"{pri_str:>{pri_w}}", |
| 505 | strat, |
| 506 | ] |
| 507 | if has_comments: |
| 508 | comment = sanitize_display(rule.comment) |
| 509 | line_parts.append(comment) |
| 510 | print(" ".join(line_parts)) |
| 511 | |
| 512 | def run_check(args: argparse.Namespace) -> None: |
| 513 | """Resolve the merge strategy for each given path. |
| 514 | |
| 515 | Uses first-match rule semantics (same as the merge engine). Returns |
| 516 | ``rule_index`` (0-based declaration order) so results can be correlated back |
| 517 | to ``muse attributes list`` output. A ``rule_index`` of ``-1`` means no rule |
| 518 | matched and the default ``"auto"`` strategy applies. |
| 519 | |
| 520 | Agent quickstart |
| 521 | ---------------- |
| 522 | :: |
| 523 | |
| 524 | muse attributes check src/foo.py --json |
| 525 | muse attributes check a.mid b.mid --dimension pitch_bend --json |
| 526 | muse attributes check src/foo.py --match-required --json |
| 527 | |
| 528 | JSON fields |
| 529 | ----------- |
| 530 | results List of one result per input path, in argument order. |
| 531 | |
| 532 | Each result: |
| 533 | |
| 534 | path Input path string (verbatim). |
| 535 | dimension Dimension used for resolution (echoed from ``-d``; default ``"*"``). |
| 536 | strategy Resolved strategy: ``"ours"``, ``"theirs"``, ``"auto"``, etc. |
| 537 | rule_index 0-based source index of the matched rule; ``-1`` if no rule matched. |
| 538 | |
| 539 | Exit codes |
| 540 | ---------- |
| 541 | 0 All paths resolved (no-match defaults are still success). |
| 542 | 1 Null byte in path, TOML error, unknown strategy, or ``--match-required`` triggered. |
| 543 | 2 Not inside a Muse repository. |
| 544 | """ |
| 545 | elapsed = start_timer() |
| 546 | json_out: bool = args.json_out |
| 547 | paths: list[str] = args.paths |
| 548 | dimension: str = args.dimension |
| 549 | match_required: bool = args.match_required |
| 550 | |
| 551 | root = require_repo() |
| 552 | try: |
| 553 | _, rules = load_attributes_full(root) |
| 554 | except ValueError as exc: |
| 555 | print(f"❌ {exc}", file=sys.stderr) |
| 556 | raise SystemExit(ExitCode.USER_ERROR.value) from exc |
| 557 | |
| 558 | results: list[_CheckResultJson] = [] |
| 559 | for raw_path in paths: |
| 560 | if "\x00" in raw_path: |
| 561 | print(f"❌ null byte in path: {raw_path!r}", file=sys.stderr) |
| 562 | raise SystemExit(ExitCode.USER_ERROR.value) |
| 563 | strategy, rule_idx = _resolve_with_index(rules, raw_path, dimension) |
| 564 | results.append( |
| 565 | _CheckResultJson( |
| 566 | path=raw_path, |
| 567 | dimension=dimension, |
| 568 | strategy=strategy, |
| 569 | rule_index=rule_idx, |
| 570 | ) |
| 571 | ) |
| 572 | |
| 573 | unmatched = [r for r in results if r["rule_index"] == -1] |
| 574 | |
| 575 | if json_out: |
| 576 | print(json.dumps(_CheckJson(**make_envelope(elapsed), results=results))) |
| 577 | if match_required and unmatched: |
| 578 | for r in unmatched: |
| 579 | print( |
| 580 | f"❌ no rule matched: {sanitize_display(r['path'])}", |
| 581 | file=sys.stderr, |
| 582 | ) |
| 583 | raise SystemExit(ExitCode.USER_ERROR.value) |
| 584 | return |
| 585 | |
| 586 | for item in results: |
| 587 | path_disp = sanitize_display(item["path"]) |
| 588 | strat_disp = sanitize_display(item["strategy"]) |
| 589 | if item["rule_index"] >= 0: |
| 590 | rule_note = f" (rule #{item['rule_index']})" |
| 591 | else: |
| 592 | rule_note = " (default)" |
| 593 | print(f"{path_disp}: {strat_disp}{rule_note}") |
| 594 | |
| 595 | if match_required and unmatched: |
| 596 | for r in unmatched: |
| 597 | print( |
| 598 | f"❌ no rule matched: {sanitize_display(r['path'])}", |
| 599 | file=sys.stderr, |
| 600 | ) |
| 601 | raise SystemExit(ExitCode.USER_ERROR.value) |
| 602 | |
| 603 | def run_validate(args: argparse.Namespace) -> None: |
| 604 | """Validate ``.museattributes`` for syntax and semantic correctness. |
| 605 | |
| 606 | Checks: file exists, TOML is valid and within 1 MiB, all rules carry |
| 607 | required fields (``path``, ``dimension``, ``strategy``), and every strategy |
| 608 | value is recognised. Exits 0 only when all checks pass. |
| 609 | |
| 610 | Agent quickstart |
| 611 | ---------------- |
| 612 | :: |
| 613 | |
| 614 | muse attributes validate --json |
| 615 | muse attributes validate --json | python3 -c "import sys,json; print(json.load(sys.stdin)['valid'])" |
| 616 | |
| 617 | JSON fields |
| 618 | ----------- |
| 619 | valid ``true`` when all checks pass; ``false`` otherwise. |
| 620 | rule_count Number of rules parsed; ``0`` when ``valid`` is ``false``. |
| 621 | errors List of error objects (empty on success). |
| 622 | |
| 623 | Each error: |
| 624 | |
| 625 | kind ``"missing"`` (file absent) or ``"semantic"`` (rule field invalid). |
| 626 | message Human-readable description of the error. |
| 627 | |
| 628 | Exit codes |
| 629 | ---------- |
| 630 | 0 File is valid. |
| 631 | 1 File missing, malformed, or contains unknown strategies. |
| 632 | 2 Not inside a Muse repository. |
| 633 | """ |
| 634 | elapsed = start_timer() |
| 635 | json_out: bool = args.json_out |
| 636 | |
| 637 | root = require_repo() |
| 638 | attr_file = root / ".museattributes" |
| 639 | |
| 640 | errors: list[_ValidateErrorJson] = [] |
| 641 | |
| 642 | if not attr_file.exists(): |
| 643 | errors.append( |
| 644 | _ValidateErrorJson( |
| 645 | kind="missing", |
| 646 | message=".museattributes file does not exist", |
| 647 | ) |
| 648 | ) |
| 649 | if json_out: |
| 650 | print(json.dumps(_ValidateJson(**make_envelope(elapsed), valid=False, rule_count=0, errors=errors))) |
| 651 | else: |
| 652 | print("❌ .museattributes file does not exist.", file=sys.stderr) |
| 653 | raise SystemExit(ExitCode.USER_ERROR.value) |
| 654 | |
| 655 | try: |
| 656 | meta, rules = load_attributes_full(root) |
| 657 | except ValueError as exc: |
| 658 | errors.append(_ValidateErrorJson(kind="semantic", message=str(exc))) |
| 659 | if json_out: |
| 660 | print(json.dumps(_ValidateJson(**make_envelope(elapsed), valid=False, rule_count=0, errors=errors))) |
| 661 | else: |
| 662 | print(f"❌ {errors[0]['message']}", file=sys.stderr) |
| 663 | raise SystemExit(ExitCode.USER_ERROR.value) from exc |
| 664 | |
| 665 | if json_out: |
| 666 | print(json.dumps(_ValidateJson(**make_envelope(elapsed), valid=True, rule_count=len(rules), errors=[]))) |
| 667 | return |
| 668 | |
| 669 | domain_raw = meta.get("domain") or "" |
| 670 | domain_disp = sanitize_display(domain_raw) if domain_raw else "(not set)" |
| 671 | print( |
| 672 | f"✅ .museattributes is valid — {len(rules)} rule(s), " |
| 673 | f"domain: {domain_disp}" |
| 674 | ) |
File History
1 commit
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217
chore(timeline): remove unused RationalRate import in entity.py
Human
minor
⚠
8 days ago