"""muse plumbing check-attr — query merge-strategy attributes for paths. Reads ``.museattributes``, resolves the applicable rules for each supplied path, and reports the strategy that would be applied per dimension. Useful for verifying that attribute rules are wired up correctly before a merge, and for scripting domain-aware merge drivers. Output (JSON, default):: { "domain": "midi", "rules_loaded": 3, "results": [ { "path": "tracks/drums.mid", "dimension": "*", "strategy": "ours", "rule": { "path_pattern": "drums/*", "dimension": "*", "strategy": "ours", "comment": "Drums always prefer ours.", "priority": 10, "source_index": 0 } }, { "path": "tracks/melody.mid", "dimension": "*", "strategy": "auto", "rule": null } ] } Text output (``--format text``):: tracks/drums.mid dimension=* strategy=ours (rule 0: drums/*) tracks/melody.mid dimension=* strategy=auto (no matching rule) With ``--rules-only`` (emit the loaded rule list without testing paths):: { "domain": "midi", "rules_loaded": 3, "dimension": "*", "rules": [ {"path_pattern": "drums/*", "dimension": "*", "strategy": "ours", ...} ] } Plumbing contract ----------------- - Exit 0: attributes resolved and emitted (even when no rules match). - Exit 1: bad ``--format`` value; missing path arguments when not using ``--rules-only`` or ``--stdin``. - Exit 3: I/O or TOML parse error reading ``.museattributes``. Strategies ---------- ``ours`` Conflict resolution keeps the current-branch version. ``theirs`` Conflict resolution keeps the incoming-branch version. ``union`` Conflict resolution takes the union of both sides (additive, e.g. note sets). ``base`` Conflict resolution falls back to the common ancestor. ``auto`` No rule matched; the merge engine selects the best strategy automatically. ``manual`` Conflict must be resolved manually; merge engine halts and surfaces the conflict for human or agent inspection. Agent use --------- Inspect active rules before a merge:: muse plumbing check-attr --rules-only --json Pipe paths from staging area:: muse plumbing check-attr --stdin < staged_paths.txt Query a specific dimension across many files:: muse plumbing check-attr tracks/drums.mid tracks/melody.mid --dimension notes Discover all rules that would fire for a path:: muse plumbing check-attr tracks/drums.mid --all-rules --json """ from __future__ import annotations import argparse import fnmatch import json import logging import sys from typing import TypedDict from muse.core.attributes import AttributeRule, load_attributes from muse.core.errors import ExitCode from muse.core.repo import require_repo from muse.core.validation import sanitize_display, validate_workspace_path from muse.plugins.registry import read_domain type _PerPath = dict[str, list["_RuleDict"]] logger = logging.getLogger(__name__) _FORMAT_CHOICES = ("json", "text") class _RuleDict(TypedDict): path_pattern: str dimension: str strategy: str comment: str priority: int source_index: int class _PathResult(TypedDict): path: str dimension: str strategy: str rule: _RuleDict | None def _dim_match(rule: AttributeRule, dimension: str) -> bool: """Return True when *rule* applies to *dimension*.""" return rule.dimension == "*" or rule.dimension == dimension or dimension == "*" def _resolve_with_rule( rules: list[AttributeRule], path: str, dimension: str, ) -> tuple[str, AttributeRule | None]: """Single-pass resolution: return ``(strategy, first_matching_rule)``. Replaces the previous pattern of calling ``resolve_strategy`` then ``_find_matching_rule`` separately, which iterated the rule list twice. Returns ``("auto", None)`` when no rule matches. """ for rule in rules: if fnmatch.fnmatch(path, rule.path_pattern) and _dim_match(rule, dimension): return rule.strategy, rule return "auto", None def _all_matching_rules( rules: list[AttributeRule], path: str, dimension: str, ) -> list[AttributeRule]: """Return every rule that matches *path* and *dimension* (for ``--all-rules``).""" return [ rule for rule in rules if fnmatch.fnmatch(path, rule.path_pattern) and _dim_match(rule, dimension) ] def _rule_to_dict(rule: AttributeRule) -> _RuleDict: return { "path_pattern": rule.path_pattern, "dimension": rule.dimension, "strategy": rule.strategy, "comment": rule.comment, "priority": rule.priority, "source_index": rule.source_index, } def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the check-attr subcommand.""" parser = subparsers.add_parser( "check-attr", help="Query merge-strategy attributes for workspace paths.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "paths", nargs="*", help=( "Workspace-relative paths to check. " "Required unless --stdin or --rules-only is used." ), ) parser.add_argument( "--stdin", action="store_true", dest="from_stdin", help=( "Read additional paths from stdin, one per line. " "Blank lines and '#'-comments are skipped. " "Combines with positional path arguments." ), ) parser.add_argument( "--dimension", "-d", default="*", dest="dimension", metavar="DIMENSION", help=( "Domain dimension to query (e.g. 'notes', 'pitch_bend'). " "Use '*' to match any dimension. (default: *)" ), ) parser.add_argument( "--format", "-f", dest="fmt", default="json", metavar="FORMAT", help="Output format: json or text. (default: json)", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) parser.add_argument( "--all-rules", "-A", action="store_true", dest="all_rules", help="For each path, list all matching rules (not just the first).", ) parser.add_argument( "--rules-only", action="store_true", dest="rules_only", help=( "Emit the full loaded rule list without testing any paths. " "No path arguments needed. " "Useful for agents inspecting attribute configuration before staging." ), ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Query merge-strategy attributes for one or more paths. Reads ``.museattributes`` from the repository root and reports the strategy that would be applied to each path for the given dimension. Default mode performs a single rule-list pass per path (not two passes), returning both the effective strategy and the first matching rule in O(N) where N is the number of rules. """ fmt: str = args.fmt cli_paths: list[str] = args.paths or [] from_stdin: bool = args.from_stdin dimension: str = args.dimension all_rules_mode: bool = args.all_rules rules_only: bool = args.rules_only if fmt not in _FORMAT_CHOICES: print( json.dumps( {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"} ), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() domain = read_domain(root) try: rules = load_attributes(root, domain=domain) except ValueError as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR) # --rules-only: emit loaded rule list without requiring path args. if rules_only: rule_dicts = [_rule_to_dict(r) for r in rules] if fmt == "text": if not rules: print("(no rules)") for rd in rule_dicts: print( f"{sanitize_display(rd['path_pattern'])} " f"dimension={sanitize_display(rd['dimension'])} " f"strategy={sanitize_display(rd['strategy'])}" ) else: print(json.dumps({ "domain": domain, "rules_loaded": len(rules), "dimension": dimension, "rules": rule_dicts, })) return # Collect paths: positional args + optional stdin. # Strip \r\n (not just \n) so CRLF-terminated input on Windows or from # carriage-return-injecting agents does not embed \r in path strings. all_paths: list[str] = list(cli_paths) if from_stdin: for line in sys.stdin: stripped = line.rstrip("\r\n") if stripped and not stripped.startswith("#"): all_paths.append(stripped) if not all_paths: print( json.dumps({"error": "At least one path argument is required."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Validate paths: reject traversal sequences, null bytes, absolute paths, # control characters, and excessively long values. for p in all_paths: try: validate_workspace_path(p) except ValueError as exc: print( json.dumps({"error": f"Invalid path {p!r}: {exc}"}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # --all-rules: every rule that fires for each path. if all_rules_mode: per_path: _PerPath = { path: [_rule_to_dict(r) for r in _all_matching_rules(rules, path, dimension)] for path in all_paths } if fmt == "text": for path, matched_rules in per_path.items(): if not matched_rules: print(f"{sanitize_display(path)} (no matching rules)") else: for rd in matched_rules: print( f"{sanitize_display(path)} " f"dimension={sanitize_display(rd['dimension'])} " f"strategy={sanitize_display(rd['strategy'])} " f"(rule {rd['source_index']}: " f"{sanitize_display(rd['path_pattern'])})" ) return print(json.dumps({ "domain": domain, "rules_loaded": len(rules), "dimension": dimension, "results": [ {"path": path, "matching_rules": per_path[path]} for path in all_paths ], })) return # Default: first-match winner per path — single O(N) pass per path. results: list[_PathResult] = [] for path in all_paths: strategy, matched_rule = _resolve_with_rule(rules, path, dimension) results.append({ "path": path, "dimension": dimension, "strategy": strategy, "rule": _rule_to_dict(matched_rule) if matched_rule else None, }) if fmt == "text": for res in results: rule_entry = res["rule"] if rule_entry is not None: rule_info = ( f"(rule {rule_entry['source_index']}: " f"{sanitize_display(rule_entry['path_pattern'])})" ) else: rule_info = "(no matching rule)" print( f"{sanitize_display(res['path'])} " f"dimension={sanitize_display(res['dimension'])} " f"strategy={sanitize_display(res['strategy'])} " f"{rule_info}" ) return print(json.dumps({ "domain": domain, "rules_loaded": len(rules), "dimension": dimension, "results": [dict(r) for r in results], }))