gabriel / muse public
hooks.py python
132 lines 4.9 KB
Raw
sha256:4802c0281407b5de70ce1b34bbe1d1cd055533cbd4b8be35861a6a3eb545c1dc feat(#192): Phase 1 — .musehooks.toml parser + `muse hooks list` Sonnet 5 patch 5 days ago
1 """Muse hooks — ``.musehooks.toml`` parser and validator.
2
3 ``.musehooks.toml`` lives in the repository root (next to ``.museignore``,
4 ``.museattributes``, and ``.museagent.md``) and declares commands to run at
5 specific commit-lifecycle points. Unlike ``.muse/hooks/`` (which would live
6 inside the object-store directory and therefore could never be committed,
7 pushed, or survive a clone — ``.muse/`` is unconditionally excluded from
8 every snapshot, see :mod:`muse.core.paths`), ``.musehooks.toml`` is an
9 ordinary tracked file: it is shared automatically via clone/push/pull.
10
11 Hooks declared here are **never executed automatically**. A human or agent
12 must explicitly run ``muse hooks install`` in a given clone to activate them
13 locally (musehub#192 Phase 2) — this mirrors git's own precedent of never
14 auto-running code from a freshly cloned repository, and keeps a malicious or
15 compromised repo from gaining code execution merely by being cloned.
16
17 MVP scope (musehub#192): exactly one hook point, ``pre-commit``, and a
18 declarative list of shell commands — no arbitrary hook scripts, no hashbang
19 files. Each command's exit code determines pass/fail when ``muse commit``
20 runs installed hooks (Phase 3).
21
22 Format
23 ------
24
25 .. code-block:: toml
26
27 # .musehooks.toml
28 [pre-commit]
29 commands = [
30 "muse agent-config status --fail-if-out-of-sync",
31 ]
32
33 Public API
34 ----------
35
36 - :class:`HooksFile` — parsed representation of ``.musehooks.toml``.
37 - :data:`VALID_HOOK_POINTS` — the set of recognised ``[<hook-point>]`` section names.
38 - :func:`load_hooks` — read ``.musehooks.toml`` from a repo root.
39 """
40
41 import tomllib
42 from dataclasses import dataclass, field
43
44 _FILENAME = ".musehooks.toml"
45
46 # MVP is deliberately scoped to pre-commit only — see musehub#192 "Out of Scope".
47 VALID_HOOK_POINTS: frozenset[str] = frozenset({"pre-commit"})
48
49 # 1 MiB cap — prevents OOM from a crafted or corrupted .musehooks.toml,
50 # matching the precedent set by .museattributes (muse/core/attributes.py).
51 _MAX_HOOKS_BYTES: int = 1 * 1024 * 1024
52
53
54 @dataclass(frozen=True)
55 class HooksFile:
56 """Parsed representation of ``.musehooks.toml``.
57
58 Attributes:
59 hooks: Mapping of hook point name (e.g. ``"pre-commit"``) to its
60 ordered list of shell commands. A hook point with no
61 ``commands`` key, or an empty ``commands = []``, maps to an
62 empty list — both are valid, meaning "hook point declared but
63 nothing to run yet."
64 """
65
66 hooks: dict[str, list[str]] = field(default_factory=dict)
67
68
69 def load_hooks(root) -> HooksFile: # noqa: ANN001 - accepts pathlib.Path
70 """Parse ``.musehooks.toml`` from *root* and return a :class:`HooksFile`.
71
72 A missing file is not an error — it means no hooks are defined, and
73 returns an empty :class:`HooksFile`. This matches
74 :func:`muse.core.attributes.load_attributes`'s precedent: absence is a
75 valid, common state, not a failure.
76
77 Args:
78 root: Repository root directory (``pathlib.Path``).
79
80 Returns:
81 The parsed :class:`HooksFile`. Empty (``hooks={}``) when the file is
82 absent, empty, or contains only comments.
83
84 Raises:
85 ValueError: If the file exceeds the 1 MiB size cap, contains invalid
86 TOML syntax, declares an unknown hook point, or a hook point's
87 ``commands`` value is not a list of strings.
88 """
89 hooks_file = root / _FILENAME
90 if not hooks_file.exists():
91 return HooksFile(hooks={})
92
93 raw_bytes = hooks_file.read_bytes()
94 if len(raw_bytes) > _MAX_HOOKS_BYTES:
95 raise ValueError(
96 f"{_FILENAME}: file too large ({len(raw_bytes):,} bytes > "
97 f"{_MAX_HOOKS_BYTES:,} byte limit)"
98 )
99
100 try:
101 raw = tomllib.loads(raw_bytes.decode("utf-8"))
102 except tomllib.TOMLDecodeError as exc:
103 raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc
104
105 hooks: dict[str, list[str]] = {}
106 for section_name, section_value in raw.items():
107 if section_name not in VALID_HOOK_POINTS:
108 raise ValueError(
109 f"{_FILENAME}: unknown hook point [{section_name}]. "
110 f"Valid hook points: {sorted(VALID_HOOK_POINTS)}"
111 )
112 if not isinstance(section_value, dict):
113 raise ValueError(
114 f"{_FILENAME}: [{section_name}] must be a table"
115 )
116
117 commands_raw = section_value.get("commands", [])
118 if not isinstance(commands_raw, list):
119 raise ValueError(
120 f"{_FILENAME}: [{section_name}].commands must be a list of strings"
121 )
122 commands: list[str] = []
123 for idx, cmd in enumerate(commands_raw):
124 if not isinstance(cmd, str):
125 raise ValueError(
126 f"{_FILENAME}: [{section_name}].commands[{idx}] must be "
127 f"a string, got {type(cmd).__name__}"
128 )
129 commands.append(cmd)
130 hooks[section_name] = commands
131
132 return HooksFile(hooks=hooks)
File History 1 commit
sha256:4802c0281407b5de70ce1b34bbe1d1cd055533cbd4b8be35861a6a3eb545c1dc feat(#192): Phase 1 — .musehooks.toml parser + `muse hooks list` Sonnet 5 patch 5 days ago