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