cgcardona / muse public
plugin.py python
755 lines 28.3 KB
12559ad7 feat: supercharge .museattributes — base/union strategies, priority, co… Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Code domain plugin — semantic version control for source code.
2
3 This plugin implements :class:`~muse.domain.MuseDomainPlugin` and
4 :class:`~muse.domain.StructuredMergePlugin` for software repositories.
5
6 Philosophy
7 ----------
8 Git models files as sequences of lines. The code plugin models them as
9 **collections of named symbols** — functions, classes, methods, variables.
10 Two commits that only reformat a Python file (no semantic change) produce
11 identical symbol ``content_id`` values and therefore *no* structured delta.
12 Two commits that rename a function produce a ``ReplaceOp`` annotated
13 ``"renamed to bar"`` rather than a red/green line diff.
14
15 Live State
16 ----------
17 ``LiveState`` is either a ``pathlib.Path`` pointing to ``muse-work/`` or a
18 ``SnapshotManifest`` dict. The path form is used by the CLI; the dict form
19 is used by in-memory merge and diff operations.
20
21 Snapshot Format
22 ---------------
23 A code snapshot is a ``SnapshotManifest``:
24
25 .. code-block:: json
26
27 {
28 "files": {
29 "src/utils.py": "<sha256-of-raw-bytes>",
30 "README.md": "<sha256-of-raw-bytes>"
31 },
32 "domain": "code"
33 }
34
35 The ``files`` values are **raw-bytes SHA-256 hashes** (not AST hashes).
36 This ensures the object store can correctly restore files verbatim on
37 ``muse checkout``. Semantic identity (AST-based hashing) is used only
38 inside ``diff()`` when constructing the structured delta.
39
40 Delta Format
41 ------------
42 ``diff()`` returns a ``StructuredDelta``. For Python files (and other
43 languages with adapters) it produces ``PatchOp`` entries whose ``child_ops``
44 carry symbol-level operations:
45
46 - ``InsertOp`` — a symbol was added (address ``"src/utils.py::my_func"``).
47 - ``DeleteOp`` — a symbol was removed.
48 - ``ReplaceOp`` — a symbol changed. The ``new_summary`` field describes the
49 change: ``"renamed to bar"``, ``"implementation changed"``, etc.
50
51 Non-Python files produce coarse ``InsertOp`` / ``DeleteOp`` / ``ReplaceOp``
52 at the file level.
53
54 Merge Semantics
55 ---------------
56 The plugin implements :class:`~muse.domain.StructuredMergePlugin` so that
57 OT-aware merges detect conflicts at *symbol* granularity:
58
59 - Agent A modifies ``foo()`` and Agent B modifies ``bar()`` in the same
60 file → **auto-merge** (ops commute).
61 - Both agents modify ``foo()`` → **symbol-level conflict** at address
62 ``"src/utils.py::foo"`` rather than a coarse file conflict.
63
64 Schema
65 ------
66 The code domain schema declares five dimensions:
67
68 ``structure``
69 The module/file tree — ``TreeSchema`` with GumTree diff.
70
71 ``symbols``
72 The AST symbol tree — ``TreeSchema`` with GumTree diff.
73
74 ``imports``
75 The import set — ``SetSchema`` with ``by_content`` identity.
76
77 ``variables``
78 Top-level variable assignments — ``SetSchema``.
79
80 ``metadata``
81 Configuration and non-code files — ``SetSchema``.
82 """
83 from __future__ import annotations
84
85 import hashlib
86 import logging
87 import pathlib
88
89 from muse.core.attributes import load_attributes, resolve_strategy
90 from muse.core.diff_algorithms import snapshot_diff
91 from muse.core.ignore import is_ignored, load_patterns
92 from muse.core.object_store import read_object
93 from muse.core.op_transform import merge_op_lists, ops_commute
94 from muse.core.schema import (
95 DimensionSpec,
96 DomainSchema,
97 SetSchema,
98 TreeSchema,
99 )
100 from muse.domain import (
101 DeleteOp,
102 DomainOp,
103 DriftReport,
104 InsertOp,
105 LiveState,
106 MergeResult,
107 PatchOp,
108 ReplaceOp,
109 SnapshotManifest,
110 StateDelta,
111 StateSnapshot,
112 StructuredDelta,
113 )
114 from muse.plugins.code.ast_parser import (
115 SymbolTree,
116 adapter_for_path,
117 parse_symbols,
118 )
119 from muse.plugins.code.symbol_diff import (
120 build_diff_ops,
121 delta_summary,
122 )
123
124 logger = logging.getLogger(__name__)
125
126 _DOMAIN_NAME = "code"
127
128 # Directories that are never versioned regardless of .museignore.
129 # These are implicit ignores that apply to all code repositories.
130 _ALWAYS_IGNORE_DIRS: frozenset[str] = frozenset({
131 ".git",
132 ".muse",
133 "__pycache__",
134 ".mypy_cache",
135 ".pytest_cache",
136 ".ruff_cache",
137 "node_modules",
138 ".venv",
139 "venv",
140 ".tox",
141 "dist",
142 "build",
143 ".eggs",
144 ".DS_Store",
145 })
146
147
148 class CodePlugin:
149 """Muse domain plugin for software source code repositories.
150
151 Implements all six core protocol methods plus the optional
152 :class:`~muse.domain.StructuredMergePlugin` OT extension. The plugin
153 does not implement :class:`~muse.domain.CRDTPlugin` — source code is
154 human-authored and benefits from explicit conflict resolution rather
155 than automatic convergence.
156
157 The plugin is stateless. The module-level singleton :data:`plugin` is
158 the standard entry point.
159 """
160
161 # ------------------------------------------------------------------
162 # 1. snapshot
163 # ------------------------------------------------------------------
164
165 def snapshot(self, live_state: LiveState) -> StateSnapshot:
166 """Capture the current ``muse-work/`` directory as a snapshot dict.
167
168 Walks all regular files under *live_state*, hashing each one with
169 SHA-256 (raw bytes). Honours ``.museignore`` and always ignores
170 known tool-generated directories (``__pycache__``, ``.git``, etc.).
171
172 Args:
173 live_state: A ``pathlib.Path`` pointing to ``muse-work/``, or an
174 existing ``SnapshotManifest`` dict (returned as-is).
175
176 Returns:
177 A ``SnapshotManifest`` mapping workspace-relative POSIX paths to
178 their SHA-256 raw-bytes digests.
179 """
180 if not isinstance(live_state, pathlib.Path):
181 return live_state
182
183 workdir = live_state
184 # .museignore lives in the repo root (parent of muse-work/).
185 repo_root = workdir.parent
186 patterns = load_patterns(repo_root)
187
188 files: dict[str, str] = {}
189 for p in sorted(workdir.rglob("*")):
190 if not p.is_file():
191 continue
192 # Skip always-ignored directories by checking path parts.
193 if any(part in _ALWAYS_IGNORE_DIRS for part in p.parts):
194 continue
195 rel = p.relative_to(workdir).as_posix()
196 if is_ignored(rel, patterns):
197 continue
198 files[rel] = _hash_file(p)
199
200 return SnapshotManifest(files=files, domain=_DOMAIN_NAME)
201
202 # ------------------------------------------------------------------
203 # 2. diff
204 # ------------------------------------------------------------------
205
206 def diff(
207 self,
208 base: StateSnapshot,
209 target: StateSnapshot,
210 *,
211 repo_root: pathlib.Path | None = None,
212 ) -> StateDelta:
213 """Compute the structured delta between two snapshots.
214
215 Without ``repo_root``
216 Produces coarse file-level ops (``InsertOp`` / ``DeleteOp`` /
217 ``ReplaceOp``). Used by ``muse checkout`` which only needs file
218 paths.
219
220 With ``repo_root``
221 Reads source bytes from the object store, parses AST for
222 supported languages (Python), and produces ``PatchOp`` entries
223 with symbol-level ``child_ops``. Used by ``muse commit`` (to
224 store the structured delta) and ``muse show`` / ``muse diff``.
225
226 Args:
227 base: Base snapshot (older state).
228 target: Target snapshot (newer state).
229 repo_root: Repository root for object-store access and symbol
230 extraction. ``None`` → file-level ops only.
231
232 Returns:
233 A ``StructuredDelta`` with ``domain="code"``.
234 """
235 base_files = base["files"]
236 target_files = target["files"]
237
238 if repo_root is None:
239 # snapshot_diff provides the free file-level diff promised by the
240 # DomainSchema architecture: any plugin that declares a schema can
241 # call this instead of writing file-set algebra from scratch.
242 return snapshot_diff(self.schema(), base, target)
243
244 ops = _semantic_ops(base_files, target_files, repo_root)
245 summary = delta_summary(ops)
246 return StructuredDelta(domain=_DOMAIN_NAME, ops=ops, summary=summary)
247
248 # ------------------------------------------------------------------
249 # 3. merge
250 # ------------------------------------------------------------------
251
252 def merge(
253 self,
254 base: StateSnapshot,
255 left: StateSnapshot,
256 right: StateSnapshot,
257 *,
258 repo_root: pathlib.Path | None = None,
259 ) -> MergeResult:
260 """Three-way merge at file granularity, respecting ``.museattributes``.
261
262 Standard three-way logic, augmented by per-path strategy overrides
263 declared in ``.museattributes``:
264
265 - Both sides agree → consensus wins (including both deleted).
266 - Only one side changed → take that side.
267 - Both sides changed differently → consult ``.museattributes``:
268
269 - ``ours`` — take left; remove from conflict list.
270 - ``theirs`` — take right; remove from conflict list.
271 - ``base`` — revert to the common ancestor; remove from conflicts.
272 - ``union`` — keep all additions from both sides; prefer left for
273 conflicting blobs; remove from conflict list.
274 - ``manual`` — force into conflict list regardless of auto resolution.
275 - ``auto`` — default three-way conflict.
276
277 This is the fallback used by ``muse cherry-pick`` and contexts where
278 the OT merge path is not available. :meth:`merge_ops` provides
279 symbol-level conflict detection when both sides have structured deltas.
280
281 Args:
282 base: Common ancestor snapshot.
283 left: Our branch snapshot.
284 right: Their branch snapshot.
285 repo_root: Repository root; when provided, ``.museattributes`` is
286 consulted for per-path strategy overrides.
287
288 Returns:
289 A ``MergeResult`` with the reconciled snapshot, any file-level
290 conflicts, and ``applied_strategies`` recording which rules fired.
291 """
292 attrs = load_attributes(repo_root, domain=_DOMAIN_NAME) if repo_root else []
293
294 base_files = base["files"]
295 left_files = left["files"]
296 right_files = right["files"]
297
298 merged: dict[str, str] = dict(base_files)
299 conflicts: list[str] = []
300 applied_strategies: dict[str, str] = {}
301
302 all_paths = set(base_files) | set(left_files) | set(right_files)
303 for path in sorted(all_paths):
304 b = base_files.get(path)
305 l = left_files.get(path)
306 r = right_files.get(path)
307
308 if l == r:
309 # Both sides agree — or both deleted.
310 if l is None:
311 merged.pop(path, None)
312 else:
313 merged[path] = l
314 # Honour "manual" override even on clean paths.
315 if attrs and resolve_strategy(attrs, path) == "manual":
316 conflicts.append(path)
317 applied_strategies[path] = "manual"
318 elif b == l:
319 # Only right changed.
320 if r is None:
321 merged.pop(path, None)
322 else:
323 merged[path] = r
324 if attrs and resolve_strategy(attrs, path) == "manual":
325 conflicts.append(path)
326 applied_strategies[path] = "manual"
327 elif b == r:
328 # Only left changed.
329 if l is None:
330 merged.pop(path, None)
331 else:
332 merged[path] = l
333 if attrs and resolve_strategy(attrs, path) == "manual":
334 conflicts.append(path)
335 applied_strategies[path] = "manual"
336 else:
337 # Both sides changed differently — consult attributes.
338 strategy = resolve_strategy(attrs, path) if attrs else "auto"
339 if strategy == "ours":
340 merged[path] = l or b or ""
341 applied_strategies[path] = "ours"
342 elif strategy == "theirs":
343 merged[path] = r or b or ""
344 applied_strategies[path] = "theirs"
345 elif strategy == "base":
346 if b is None:
347 merged.pop(path, None)
348 else:
349 merged[path] = b
350 applied_strategies[path] = "base"
351 elif strategy == "union":
352 # For file-level blobs, full union is not representable —
353 # prefer left and keep all additions from both branches.
354 merged[path] = l or r or b or ""
355 applied_strategies[path] = "union"
356 elif strategy == "manual":
357 conflicts.append(path)
358 merged[path] = l or r or b or ""
359 applied_strategies[path] = "manual"
360 else:
361 # "auto" — standard three-way conflict.
362 conflicts.append(path)
363 merged[path] = l or r or b or ""
364
365 return MergeResult(
366 merged=SnapshotManifest(files=merged, domain=_DOMAIN_NAME),
367 conflicts=conflicts,
368 applied_strategies=applied_strategies,
369 )
370
371 # ------------------------------------------------------------------
372 # 4. drift
373 # ------------------------------------------------------------------
374
375 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
376 """Report how much the working tree has drifted from the last commit.
377
378 Called by ``muse status``. Takes a snapshot of the current live
379 state and diffs it against the committed snapshot.
380
381 Args:
382 committed: The last committed snapshot.
383 live: Current live state (path or snapshot manifest).
384
385 Returns:
386 A ``DriftReport`` describing what has changed since the last commit.
387 """
388 current = self.snapshot(live)
389 delta = self.diff(committed, current)
390 return DriftReport(
391 has_drift=len(delta["ops"]) > 0,
392 summary=delta["summary"],
393 delta=delta,
394 )
395
396 # ------------------------------------------------------------------
397 # 5. apply
398 # ------------------------------------------------------------------
399
400 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
401 """Apply a delta to the working tree.
402
403 Called by ``muse checkout`` after the core engine has already
404 restored file-level objects from the object store. The code plugin
405 has no domain-specific post-processing to perform, so this is a
406 pass-through.
407
408 Args:
409 delta: The typed operation list (unused at post-checkout time).
410 live_state: Current live state (returned unchanged).
411
412 Returns:
413 *live_state* unchanged.
414 """
415 return live_state
416
417 # ------------------------------------------------------------------
418 # 6. schema
419 # ------------------------------------------------------------------
420
421 def schema(self) -> DomainSchema:
422 """Declare the structural schema of the code domain.
423
424 Returns:
425 A ``DomainSchema`` with five semantic dimensions:
426 ``structure``, ``symbols``, ``imports``, ``variables``,
427 and ``metadata``.
428 """
429 return DomainSchema(
430 domain=_DOMAIN_NAME,
431 description=(
432 "Semantic version control for source code. "
433 "Treats code as a structured system of named symbols "
434 "(functions, classes, methods) rather than lines of text. "
435 "Two commits that only reformat a file produce no delta. "
436 "Renames and moves are detected via content-addressed "
437 "symbol identity."
438 ),
439 top_level=TreeSchema(
440 kind="tree",
441 node_type="module",
442 diff_algorithm="gumtree",
443 ),
444 dimensions=[
445 DimensionSpec(
446 name="structure",
447 description=(
448 "Module / file tree. Tracks which files exist and "
449 "how they relate to each other."
450 ),
451 schema=TreeSchema(
452 kind="tree",
453 node_type="file",
454 diff_algorithm="gumtree",
455 ),
456 independent_merge=False,
457 ),
458 DimensionSpec(
459 name="symbols",
460 description=(
461 "AST symbol tree. Functions, classes, methods, and "
462 "variables — the primary unit of semantic change."
463 ),
464 schema=TreeSchema(
465 kind="tree",
466 node_type="symbol",
467 diff_algorithm="gumtree",
468 ),
469 independent_merge=True,
470 ),
471 DimensionSpec(
472 name="imports",
473 description=(
474 "Import set. Tracks added / removed import statements "
475 "as an unordered set — order is semantically irrelevant."
476 ),
477 schema=SetSchema(
478 kind="set",
479 element_type="import",
480 identity="by_content",
481 ),
482 independent_merge=True,
483 ),
484 DimensionSpec(
485 name="variables",
486 description=(
487 "Top-level variable and constant assignments. "
488 "Tracked as an unordered set."
489 ),
490 schema=SetSchema(
491 kind="set",
492 element_type="variable",
493 identity="by_content",
494 ),
495 independent_merge=True,
496 ),
497 DimensionSpec(
498 name="metadata",
499 description=(
500 "Non-code files: configuration, documentation, "
501 "build scripts, etc. Tracked at file granularity."
502 ),
503 schema=SetSchema(
504 kind="set",
505 element_type="file",
506 identity="by_content",
507 ),
508 independent_merge=True,
509 ),
510 ],
511 merge_mode="three_way",
512 schema_version=1,
513 )
514
515 # ------------------------------------------------------------------
516 # StructuredMergePlugin — OT extension
517 # ------------------------------------------------------------------
518
519 def merge_ops(
520 self,
521 base: StateSnapshot,
522 ours_snap: StateSnapshot,
523 theirs_snap: StateSnapshot,
524 ours_ops: list[DomainOp],
525 theirs_ops: list[DomainOp],
526 *,
527 repo_root: pathlib.Path | None = None,
528 ) -> MergeResult:
529 """Operation-level three-way merge using Operational Transformation.
530
531 Uses :func:`~muse.core.op_transform.merge_op_lists` to determine
532 which ``DomainOp`` pairs commute (auto-mergeable) and which conflict.
533 For ``PatchOp`` entries at the same file address, the engine recurses
534 into ``child_ops`` — so two agents modifying *different* functions in
535 the same file auto-merge, while concurrent modifications to the *same*
536 function produce a symbol-level conflict address.
537
538 The reconciled ``merged`` snapshot is produced by the file-level
539 three-way :meth:`merge` fallback (we cannot reconstruct merged source
540 bytes without a text-merge pass). This is correct for all cases where
541 the two sides touched *different* files. For the same-file-different-
542 symbol case the merged manifest holds the *ours* version of the file —
543 annotated as a conflict-free merge — which may require the user to
544 re-apply the theirs changes manually. This limitation is documented
545 and will be lifted in a future release that implements source-level
546 patching.
547
548 Args:
549 base: Common ancestor snapshot.
550 ours_snap: Our branch's final snapshot.
551 theirs_snap: Their branch's final snapshot.
552 ours_ops: Our branch's typed operation list.
553 theirs_ops: Their branch's typed operation list.
554 repo_root: Repository root for ``.museattributes`` lookup.
555
556 Returns:
557 A ``MergeResult`` where ``conflicts`` contains symbol-level
558 addresses (e.g. ``"src/utils.py::calculate_total"``) rather than
559 bare file paths.
560 """
561 # The core OT engine's _op_key for PatchOp hashes only the file path
562 # and child_domain — not the child_ops themselves. This means two
563 # PatchOps for the same file are treated as "consensus" regardless of
564 # whether they touch the same or different symbols. We therefore
565 # implement symbol-level conflict detection directly here.
566
567 attrs = load_attributes(repo_root, domain=_DOMAIN_NAME) if repo_root else []
568
569 # ── Step 1: symbol-level conflict detection for PatchOps ──────────
570 ours_patches: dict[str, PatchOp] = {
571 op["address"]: op for op in ours_ops if op["op"] == "patch"
572 }
573 theirs_patches: dict[str, PatchOp] = {
574 op["address"]: op for op in theirs_ops if op["op"] == "patch"
575 }
576
577 conflict_addresses: set[str] = set()
578 for path in ours_patches:
579 if path not in theirs_patches:
580 continue
581 for our_child in ours_patches[path]["child_ops"]:
582 for their_child in theirs_patches[path]["child_ops"]:
583 if not ops_commute(our_child, their_child):
584 conflict_addresses.add(our_child["address"])
585
586 # ── Step 2: coarse OT for non-PatchOp ops (file-level inserts/deletes) ──
587 non_patch_ours: list[DomainOp] = [op for op in ours_ops if op["op"] != "patch"]
588 non_patch_theirs: list[DomainOp] = [op for op in theirs_ops if op["op"] != "patch"]
589 file_result = merge_op_lists(
590 base_ops=[],
591 ours_ops=non_patch_ours,
592 theirs_ops=non_patch_theirs,
593 )
594 for our_op, _ in file_result.conflict_ops:
595 conflict_addresses.add(our_op["address"])
596
597 # ── Step 3: apply .museattributes to symbol-level conflicts ──────
598 # Symbol addresses are of the form "src/utils.py::function_name".
599 # We resolve strategy against the file path portion so that a
600 # path = "src/**/*.py" / strategy = "ours" rule suppresses symbol
601 # conflicts in those files, not just file-level manifest conflicts.
602 op_applied_strategies: dict[str, str] = {}
603 resolved_conflicts: list[str] = []
604 if attrs:
605 for addr in sorted(conflict_addresses):
606 file_path = addr.split("::")[0] if "::" in addr else addr
607 strategy = resolve_strategy(attrs, file_path)
608 if strategy in ("ours", "theirs", "base", "union"):
609 op_applied_strategies[addr] = strategy
610 elif strategy == "manual":
611 resolved_conflicts.append(addr)
612 op_applied_strategies[addr] = "manual"
613 else:
614 resolved_conflicts.append(addr)
615 else:
616 resolved_conflicts = sorted(conflict_addresses)
617
618 merged_ops: list[DomainOp] = list(file_result.merged_ops) + list(ours_ops)
619
620 # Fall back to file-level merge for the manifest (carries its own
621 # applied_strategies from file-level attribute resolution).
622 fallback = self.merge(base, ours_snap, theirs_snap, repo_root=repo_root)
623 combined_strategies = {**fallback.applied_strategies, **op_applied_strategies}
624 return MergeResult(
625 merged=fallback.merged,
626 conflicts=resolved_conflicts,
627 applied_strategies=combined_strategies,
628 dimension_reports=fallback.dimension_reports,
629 op_log=merged_ops,
630 )
631
632
633 # ---------------------------------------------------------------------------
634 # Private helpers
635 # ---------------------------------------------------------------------------
636
637
638 def _hash_file(path: pathlib.Path) -> str:
639 """Return the SHA-256 hex digest of *path*'s raw bytes."""
640 h = hashlib.sha256()
641 with path.open("rb") as fh:
642 for chunk in iter(lambda: fh.read(65_536), b""):
643 h.update(chunk)
644 return h.hexdigest()
645
646
647 def _file_level_ops(
648 base_files: dict[str, str],
649 target_files: dict[str, str],
650 ) -> list[DomainOp]:
651 """Produce coarse file-level ops (no AST parsing)."""
652 base_paths = set(base_files)
653 target_paths = set(target_files)
654 ops: list[DomainOp] = []
655
656 for path in sorted(target_paths - base_paths):
657 ops.append(InsertOp(
658 op="insert",
659 address=path,
660 position=None,
661 content_id=target_files[path],
662 content_summary=f"added {path}",
663 ))
664 for path in sorted(base_paths - target_paths):
665 ops.append(DeleteOp(
666 op="delete",
667 address=path,
668 position=None,
669 content_id=base_files[path],
670 content_summary=f"removed {path}",
671 ))
672 for path in sorted(base_paths & target_paths):
673 if base_files[path] != target_files[path]:
674 ops.append(ReplaceOp(
675 op="replace",
676 address=path,
677 position=None,
678 old_content_id=base_files[path],
679 new_content_id=target_files[path],
680 old_summary=f"{path} (before)",
681 new_summary=f"{path} (after)",
682 ))
683 return ops
684
685
686 def _semantic_ops(
687 base_files: dict[str, str],
688 target_files: dict[str, str],
689 repo_root: pathlib.Path,
690 ) -> list[DomainOp]:
691 """Produce symbol-level ops by reading files from the object store."""
692 base_paths = set(base_files)
693 target_paths = set(target_files)
694 changed_paths = (
695 (target_paths - base_paths) # added
696 | (base_paths - target_paths) # removed
697 | { # modified
698 p for p in base_paths & target_paths
699 if base_files[p] != target_files[p]
700 }
701 )
702
703 base_trees: dict[str, SymbolTree] = {}
704 target_trees: dict[str, SymbolTree] = {}
705
706 for path in changed_paths:
707 if path in base_files:
708 raw = read_object(repo_root, base_files[path])
709 if raw is not None:
710 base_trees[path] = _parse_with_fallback(raw, path)
711
712 if path in target_files:
713 raw = read_object(repo_root, target_files[path])
714 if raw is not None:
715 target_trees[path] = _parse_with_fallback(raw, path)
716
717 return build_diff_ops(base_files, target_files, base_trees, target_trees)
718
719
720 def _parse_with_fallback(source: bytes, file_path: str) -> SymbolTree:
721 """Parse symbols from *source*, returning an empty tree on any error."""
722 try:
723 return parse_symbols(source, file_path)
724 except Exception:
725 logger.debug("Symbol parsing failed for %s — falling back to file-level.", file_path)
726 return {}
727
728
729 def _load_symbol_trees_from_workdir(
730 workdir: pathlib.Path,
731 manifest: dict[str, str],
732 ) -> dict[str, SymbolTree]:
733 """Build symbol trees for all files in *manifest* that live in *workdir*."""
734 trees: dict[str, SymbolTree] = {}
735 for rel_path in manifest:
736 file_path = workdir / rel_path
737 if not file_path.is_file():
738 continue
739 try:
740 source = file_path.read_bytes()
741 except OSError:
742 continue
743 suffix = pathlib.PurePosixPath(rel_path).suffix.lower()
744 adapter = adapter_for_path(rel_path)
745 if adapter.supported_extensions().intersection({suffix}):
746 trees[rel_path] = _parse_with_fallback(source, rel_path)
747 return trees
748
749
750 # ---------------------------------------------------------------------------
751 # Module-level singleton
752 # ---------------------------------------------------------------------------
753
754 #: The singleton plugin instance registered in ``muse/plugins/registry.py``.
755 plugin = CodePlugin()