attributes.py python
468 lines 15.7 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """Muse attributes — ``.museattributes`` TOML parser and per-path strategy resolver.
2
3 ``.museattributes`` lives in the repository root (next to ``.muse/`` and
4 ``state/``) and declares merge strategies for specific paths and
5 dimensions. It uses TOML syntax with an optional ``[meta]`` section for
6 domain declaration and an ordered ``[[rules]]`` array.
7
8 Format
9 ------
10
11 .. code-block:: toml
12
13 # .museattributes
14 # Merge strategy overrides for this repository.
15
16 [meta]
17 domain = "midi" # optional — validated against .muse/repo.json
18
19 [[rules]]
20 path = "drums/*" # fnmatch glob against workspace-relative POSIX paths
21 dimension = "*" # domain axis name, or "*" to match any dimension
22 strategy = "ours" # resolution strategy (see below)
23 comment = "Drums are always authored by branch A — always prefer ours."
24 priority = 10 # optional; higher priority rules are tried first
25
26 [[rules]]
27 path = "keys/*"
28 dimension = "pitch_bend"
29 strategy = "theirs"
30 comment = "Remote always has the better pitch-bend automation."
31
32 [[rules]]
33 path = "*"
34 dimension = "*"
35 strategy = "auto"
36
37 Strategies
38 ----------
39
40 ``ours``
41 Take the left / current-branch version; the path is removed from the
42 conflict list.
43
44 ``theirs``
45 Take the right / incoming-branch version; the path is removed from the
46 conflict list.
47
48 ``union``
49 Include **all** additions from both sides. Deletions are honoured only
50 when **both** sides agree. For independent element sets (MIDI notes,
51 code symbol additions, import sets) this produces a combined result with
52 no conflicts. For opaque binary blobs where full unification is
53 impossible, the left / current-branch blob is preferred and the path is
54 removed from the conflict list.
55
56 ``base``
57 Revert to the common merge-base version — discard changes from *both*
58 branches. Useful for generated files, lock files, or any path that
59 should always stay at a known-good state during a merge.
60
61 ``auto``
62 Default behaviour. Defer to the engine's three-way algorithm.
63
64 ``manual``
65 Force the path into the conflict list even if the engine would
66 auto-resolve it. Use this to guarantee human review on safety-critical
67 paths.
68
69 Domain-specific strategies (Knowtation)
70 ----------------------------------------
71
72 ``prefer-newer-date``
73 Compare the ``date`` field in YAML frontmatter between the two versions
74 and take whichever note has the later date. Fallback hierarchy when a
75 date is absent or unparseable:
76
77 1. Only one side has a parseable date → prefer that side (it is more
78 intentional).
79 2. Neither side has a parseable date, or both dates are equal → ``ours``
80 (current-branch wins; no information to decide).
81
82 This strategy is ideal for inbox captures and journal entries where the
83 most recent timestamp always reflects the intended state.
84
85 ``union-sorted``
86 Like ``union`` but additionally sorts each set-valued frontmatter field
87 (``tags``, ``entity``, ``follows``, ``summarizes``, ``attachments``)
88 alphabetically in the merged result. Scalar fields defer to ``ours``.
89 Note bodies receive the same union treatment as the base ``union``
90 strategy (unmatched lines from both sides are kept).
91
92 Use this when deterministic, human-readable field order matters more than
93 preservation of the original author's ordering.
94
95 ``knowtation-3way``
96 Delegates to the Knowtation structured three-way merge algorithm
97 (implemented in ``muse/plugins/knowtation/merger.py``; Phase 2.4).
98 When the merger module is not yet available, falls back to ``auto``
99 and logs a warning. Use this as the maximum-intelligence strategy
100 for complex note merges where all four dimensions need per-layer
101 resolution.
102
103 Rule fields
104 -----------
105
106 ``path`` (required) — ``fnmatch`` glob matched against workspace-relative
107 POSIX paths (e.g. ``"tracks/*.mid"``, ``"src/**/*.py"``).
108
109 ``dimension`` (required) — domain axis name (e.g. ``"notes"``,
110 ``"pitch_bend"``, ``"symbols"``) or ``"*"`` to match any
111 dimension.
112
113 ``strategy`` (required) — one of the six strategies listed above.
114
115 ``comment`` (optional) — free-form documentation string; ignored at
116 runtime. Use it to explain *why* the rule exists.
117
118 ``priority`` (optional, default 0) — integer used to order rules before
119 file order. Higher-priority rules are evaluated first. Rules
120 with equal priority preserve their declaration order.
121
122 **First matching rule wins** after sorting by priority (descending) then
123 file order (ascending).
124
125 ``[meta]`` is optional; its absence has no effect on merge correctness.
126 When both ``[meta] domain`` and a repo ``domain`` are known, a mismatch
127 logs a warning.
128
129 Public API
130 ----------
131
132 - :class:`AttributesMeta` — TypedDict for the ``[meta]`` section.
133 - :class:`AttributesRuleDict` — TypedDict for a single ``[[rules]]`` entry.
134 - :class:`MuseAttributesFile` — TypedDict for the full parsed file.
135 - :class:`AttributeRule` — a single resolved rule (dataclass).
136 - :func:`read_attributes_meta` — read only the ``[meta]`` section.
137 - :func:`load_attributes_full` — single-pass parse returning ``(meta, rules)``.
138 - :func:`load_attributes` — read ``.museattributes`` from a repo root.
139 - :func:`resolve_strategy` — first-match strategy lookup.
140 """
141
142 from __future__ import annotations
143
144 import fnmatch
145 import logging
146 import pathlib
147 import tomllib
148 from dataclasses import dataclass, field
149 from typing import TypedDict
150
151 _logger = logging.getLogger(__name__)
152
153 VALID_STRATEGIES: frozenset[str] = frozenset(
154 {
155 # Core strategies — available to all domain plugins.
156 "ours",
157 "theirs",
158 "union",
159 "base",
160 "auto",
161 "manual",
162 # Knowtation domain-specific strategies (Phase 2.2).
163 # Implementations live in muse/plugins/knowtation/strategies.py.
164 "prefer-newer-date",
165 "union-sorted",
166 "knowtation-3way",
167 }
168 )
169
170 _FILENAME = ".museattributes"
171
172 # 1 MiB cap — prevents OOM from a crafted or corrupted .museattributes.
173 _MAX_ATTRIBUTES_BYTES: int = 1 * 1024 * 1024
174
175
176 class AttributesMeta(TypedDict, total=False):
177 """Typed representation of the ``[meta]`` section in ``.museattributes``."""
178
179 domain: str
180
181
182 class AttributesRuleDict(TypedDict, total=False):
183 """Typed representation of a single ``[[rules]]`` entry.
184
185 ``path``, ``dimension``, and ``strategy`` are required at parse time.
186 ``comment`` and ``priority`` are optional.
187 """
188
189 path: str
190 dimension: str
191 strategy: str
192 comment: str
193 priority: int
194
195
196 class MuseAttributesFile(TypedDict, total=False):
197 """Typed representation of the complete ``.museattributes`` file."""
198
199 meta: AttributesMeta
200 rules: list[AttributesRuleDict]
201
202
203 @dataclass(frozen=True)
204 class AttributeRule:
205 """A single rule resolved from ``.museattributes``.
206
207 Attributes:
208 path_pattern: ``fnmatch`` glob matched against workspace-relative paths.
209 dimension: Domain axis name (e.g. ``"notes"``) or ``"*"``.
210 strategy: Resolution strategy: one of ``ours | theirs | union |
211 base | auto | manual``.
212 comment: Human-readable annotation explaining the rule's purpose.
213 Ignored at runtime.
214 priority: Ordering weight. Higher values are evaluated before
215 lower values. Rules with equal priority preserve
216 declaration order.
217 source_index: 0-based index of the rule in the ``[[rules]]`` array.
218 """
219
220 path_pattern: str
221 dimension: str
222 strategy: str
223 comment: str = ""
224 priority: int = 0
225 source_index: int = 0
226
227
228 def _parse_raw(root: pathlib.Path) -> MuseAttributesFile:
229 """Read and TOML-parse ``.museattributes``, returning a typed file structure.
230
231 Builds ``MuseAttributesFile`` from the raw TOML dict using explicit
232 ``isinstance`` checks — no ``Any`` propagated into the return value.
233
234 Raises:
235 ValueError: On TOML syntax errors or if the file exceeds
236 ``_MAX_ATTRIBUTES_BYTES``.
237 """
238 attr_file = root / _FILENAME
239 raw_bytes = attr_file.read_bytes()
240 if len(raw_bytes) > _MAX_ATTRIBUTES_BYTES:
241 raise ValueError(
242 f"{_FILENAME}: file too large ({len(raw_bytes):,} bytes > "
243 f"{_MAX_ATTRIBUTES_BYTES:,} byte limit)"
244 )
245 try:
246 raw = tomllib.loads(raw_bytes.decode("utf-8"))
247 except tomllib.TOMLDecodeError as exc:
248 raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc
249
250 result: MuseAttributesFile = {}
251
252 # [meta] section
253 meta_raw = raw.get("meta")
254 if isinstance(meta_raw, dict):
255 meta: AttributesMeta = {}
256 domain_val = meta_raw.get("domain")
257 if isinstance(domain_val, str):
258 meta["domain"] = domain_val
259 result["meta"] = meta
260
261 # [[rules]] array
262 rules_raw = raw.get("rules")
263 if isinstance(rules_raw, list):
264 rules: list[AttributesRuleDict] = []
265 for idx, entry in enumerate(rules_raw):
266 if not isinstance(entry, dict):
267 continue
268 path_val = entry.get("path")
269 dim_val = entry.get("dimension")
270 strat_val = entry.get("strategy")
271 if (
272 isinstance(path_val, str)
273 and isinstance(dim_val, str)
274 and isinstance(strat_val, str)
275 ):
276 rule: AttributesRuleDict = {
277 "path": path_val,
278 "dimension": dim_val,
279 "strategy": strat_val,
280 }
281 comment_val = entry.get("comment")
282 if isinstance(comment_val, str):
283 rule["comment"] = comment_val
284 priority_val = entry.get("priority")
285 if isinstance(priority_val, int):
286 rule["priority"] = priority_val
287 rules.append(rule)
288 else:
289 missing = [
290 f
291 for f, v in (
292 ("path", path_val),
293 ("dimension", dim_val),
294 ("strategy", strat_val),
295 )
296 if not isinstance(v, str)
297 ]
298 raise ValueError(
299 f"{_FILENAME}: rule[{idx}] is missing required field(s): "
300 + ", ".join(missing)
301 )
302 result["rules"] = rules
303
304 return result
305
306
307 def read_attributes_meta(root: pathlib.Path) -> AttributesMeta:
308 """Return the ``[meta]`` section of ``.museattributes``, or an empty dict.
309
310 Does not validate or resolve rules — use this to inspect metadata only.
311
312 Args:
313 root: Repository root directory.
314
315 Returns:
316 The ``[meta]`` TypedDict, which may be empty if the section is absent
317 or the file does not exist.
318 """
319 attr_file = root / _FILENAME
320 if not attr_file.exists():
321 return {}
322 try:
323 parsed = _parse_raw(root)
324 except ValueError:
325 return {}
326 meta = parsed.get("meta")
327 if meta is None:
328 return {}
329 return meta
330
331
332 def load_attributes_full(
333 root: pathlib.Path,
334 *,
335 domain: str | None = None,
336 ) -> tuple[AttributesMeta, list[AttributeRule]]:
337 """Parse ``.museattributes`` in a **single pass** and return both meta and rules.
338
339 This is the preferred entry point when both the ``[meta]`` section and the
340 rule list are needed — it avoids parsing the file twice (unlike calling
341 :func:`read_attributes_meta` followed by :func:`load_attributes`).
342
343 Rules are sorted by ``priority`` (descending) then by declaration order
344 (ascending), so higher-priority rules are evaluated first.
345
346 Args:
347 root: Repository root directory.
348 domain: Optional active repo domain. When provided and the file
349 contains ``[meta] domain``, a mismatch logs a warning.
350 Pass ``None`` to skip the check.
351
352 Returns:
353 ``(meta, rules)`` — *meta* is the ``[meta]`` TypedDict (possibly
354 empty); *rules* is the priority-sorted rule list (possibly empty).
355
356 Raises:
357 ValueError: If a rule is missing required fields, contains an invalid
358 strategy, or the file exceeds ``_MAX_ATTRIBUTES_BYTES``.
359 """
360 attr_file = root / _FILENAME
361 if not attr_file.exists():
362 return {}, []
363
364 data = _parse_raw(root)
365
366 meta: AttributesMeta = data.get("meta") or {}
367
368 # Domain mismatch warning — advisory only, does not raise.
369 file_domain = meta.get("domain")
370 if file_domain and domain and file_domain != domain:
371 _logger.warning(
372 "⚠️ %s: [meta] domain %r does not match active repo domain %r — "
373 "rules may target a different domain",
374 _FILENAME,
375 file_domain,
376 domain,
377 )
378
379 raw_rules: list[AttributesRuleDict] = data.get("rules") or []
380
381 rules: list[AttributeRule] = []
382 for idx, entry in enumerate(raw_rules):
383 strategy = entry["strategy"]
384 if strategy not in VALID_STRATEGIES:
385 raise ValueError(
386 f"{_FILENAME}: rule[{idx}]: unknown strategy {strategy!r}. "
387 f"Valid strategies: {sorted(VALID_STRATEGIES)}"
388 )
389
390 rules.append(
391 AttributeRule(
392 path_pattern=entry["path"],
393 dimension=entry["dimension"],
394 strategy=strategy,
395 comment=entry.get("comment", ""),
396 priority=entry.get("priority", 0),
397 source_index=idx,
398 )
399 )
400
401 # Stable sort: higher priority first, ties preserve declaration order.
402 rules.sort(key=lambda r: -r.priority)
403 return meta, rules
404
405
406 def load_attributes(
407 root: pathlib.Path,
408 *,
409 domain: str | None = None,
410 ) -> list[AttributeRule]:
411 """Parse ``.museattributes`` from *root* and return the ordered rule list.
412
413 Delegates to :func:`load_attributes_full` — use that function directly
414 when both the ``[meta]`` section and the rule list are needed, to avoid
415 parsing the file twice.
416
417 Args:
418 root: Repository root directory.
419 domain: Optional active repo domain for mismatch warning.
420
421 Returns:
422 A list of :class:`AttributeRule` sorted by priority then file order.
423 Returns an empty list when ``.museattributes`` is absent or has no
424 valid rules.
425
426 Raises:
427 ValueError: If a rule is missing required fields or has an invalid
428 strategy.
429 """
430 _, rules = load_attributes_full(root, domain=domain)
431 return rules
432
433
434 def resolve_strategy(
435 rules: list[AttributeRule],
436 path: str,
437 dimension: str = "*",
438 ) -> str:
439 """Return the first matching strategy for *path* and *dimension*.
440
441 Matching rules:
442
443 - **path**: ``fnmatch.fnmatch(path, rule.path_pattern)`` must be ``True``.
444 - **dimension**: ``rule.dimension`` must be ``"*"`` (matches anything) **or**
445 equal *dimension*.
446
447 First-match wins after priority ordering applied by :func:`load_attributes`.
448 Returns ``"auto"`` when no rule matches.
449
450 Args:
451 rules: Rule list from :func:`load_attributes`.
452 path: Workspace-relative POSIX path (e.g. ``"tracks/drums.mid"``).
453 dimension: Domain axis name or ``"*"`` to match any rule dimension.
454
455 Returns:
456 A strategy string: ``"ours"``, ``"theirs"``, ``"union"``, ``"base"``,
457 ``"auto"``, or ``"manual"``.
458 """
459 for rule in rules:
460 path_match = fnmatch.fnmatch(path, rule.path_pattern)
461 dim_match = (
462 rule.dimension == "*"
463 or rule.dimension == dimension
464 or dimension == "*"
465 )
466 if path_match and dim_match:
467 return rule.strategy
468 return "auto"
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago