ai-push-hooks 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,398 @@
1
+ """Internal loader and dispatcher for repository-local Python workflow hooks.
2
+
3
+ This module intentionally does not provide discovery, registration, or a plugin
4
+ SDK. A :class:`PluginLoader` belongs to one workflow run. It reads each
5
+ referenced source file once and keeps the resulting module snapshot in a
6
+ canonical, per-run cache. :class:`PluginDispatcher` is the small adapter the
7
+ workflow engine can use after its normal gates and artifact resolution have
8
+ completed.
9
+ """
10
+
11
+ from __future__ import annotations # noqa: I001
12
+
13
+ import copy
14
+ import errno
15
+ import hashlib
16
+ import inspect
17
+ import os
18
+ import pathlib
19
+ import re
20
+ import stat
21
+ import threading
22
+ from collections.abc import Callable, Mapping, Sequence
23
+ from types import ModuleType
24
+ from typing import Any
25
+
26
+ from .paths import (
27
+ path_has_symlink,
28
+ relative_path_parts,
29
+ resolve_contained_path,
30
+ )
31
+ from .plugins import PluginContext, PushContext
32
+ from .types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
33
+
34
+
35
+ _CALLABLE_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
36
+ _SOURCE_ENCODING = "utf-8"
37
+ _OS_OPEN = os.open
38
+ _DESCRIPTOR_RELATIVE_SUPPORTED = bool(
39
+ getattr(os, "O_DIRECTORY", 0)
40
+ and getattr(os, "O_NOFOLLOW", 0)
41
+ and _OS_OPEN in getattr(os, "supports_dir_fd", ())
42
+ )
43
+
44
+
45
+ def _descriptor_relative_supported() -> bool:
46
+ return _DESCRIPTOR_RELATIVE_SUPPORTED
47
+
48
+
49
+ def _reference_parts(reference: str) -> tuple[str, str]:
50
+ if not isinstance(reference, str) or reference.count(":") != 1:
51
+ raise HookError(
52
+ "Python plugin reference must be a repository-relative .py path followed by :callable"
53
+ )
54
+ path_value, callable_name = reference.split(":", 1)
55
+ if not _CALLABLE_PATTERN.fullmatch(callable_name):
56
+ raise HookError("Python plugin callable must be one top-level identifier")
57
+ parts = relative_path_parts(path_value, "Python plugin path")
58
+ if not parts[-1].endswith(".py"):
59
+ raise HookError("Python plugin path must name a .py file")
60
+ return "/".join(parts), callable_name
61
+
62
+
63
+ def _canonical_root(repo_root: pathlib.Path) -> pathlib.Path:
64
+ try:
65
+ root = pathlib.Path(repo_root).resolve(strict=True)
66
+ except (OSError, RuntimeError):
67
+ raise HookError("Python plugin repository root is not accessible") from None
68
+ if not root.is_dir():
69
+ raise HookError("Python plugin repository root must be a directory")
70
+ return root
71
+
72
+
73
+ def _open_source_descriptor_relative(
74
+ root: pathlib.Path, parts: tuple[str, ...]
75
+ ) -> tuple[pathlib.Path, bytes]:
76
+ """Walk every component relative to an O_NOFOLLOW directory descriptor."""
77
+
78
+ directory_fd = -1
79
+ file_fd = -1
80
+ directory_flags = (
81
+ os.O_RDONLY
82
+ | getattr(os, "O_CLOEXEC", 0)
83
+ | getattr(os, "O_DIRECTORY", 0)
84
+ | getattr(os, "O_NOFOLLOW", 0)
85
+ )
86
+ file_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
87
+ try:
88
+ directory_fd = _OS_OPEN(root, directory_flags)
89
+ for part in parts[:-1]:
90
+ next_fd = _OS_OPEN(part, directory_flags, dir_fd=directory_fd)
91
+ os.close(directory_fd)
92
+ directory_fd = next_fd
93
+ file_fd = _OS_OPEN(parts[-1], file_flags, dir_fd=directory_fd)
94
+ metadata = os.fstat(file_fd)
95
+ if not stat.S_ISREG(metadata.st_mode):
96
+ raise HookError("Python plugin path must reference an ordinary regular file")
97
+ with os.fdopen(file_fd, "rb") as source:
98
+ file_fd = -1
99
+ return root.joinpath(*parts), source.read()
100
+ except HookError:
101
+ raise
102
+ except OSError as exc:
103
+ if exc.errno in {errno.ELOOP, errno.ENOTDIR}:
104
+ raise HookError("Python plugin path must not traverse a symlink or reparse point") from None
105
+ if exc.errno == errno.ENOENT:
106
+ raise HookError("Python plugin path must reference an existing regular file") from None
107
+ raise HookError("Python plugin path could not be opened safely") from None
108
+ finally:
109
+ if file_fd >= 0:
110
+ os.close(file_fd)
111
+ if directory_fd >= 0:
112
+ os.close(directory_fd)
113
+
114
+
115
+ def _open_source_absolute(root: pathlib.Path, relative_path: str) -> tuple[pathlib.Path, bytes]:
116
+ """Fallback for platforms without descriptor-relative open support."""
117
+
118
+ lexical_path = root.joinpath(*relative_path.split("/"))
119
+ if path_has_symlink(root, lexical_path):
120
+ raise HookError("Python plugin path must not traverse a symlink or reparse point")
121
+ callback_path = resolve_contained_path(root, relative_path, "Python plugin path")
122
+
123
+ try:
124
+ metadata = callback_path.lstat()
125
+ except (FileNotFoundError, OSError):
126
+ raise HookError("Python plugin path must reference an existing regular file") from None
127
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
128
+ if stat.S_ISLNK(metadata.st_mode) or bool(
129
+ getattr(metadata, "st_file_attributes", 0) & reparse_flag
130
+ ):
131
+ raise HookError("Python plugin path must not be a symlink or reparse point")
132
+ if not stat.S_ISREG(metadata.st_mode):
133
+ raise HookError("Python plugin path must reference an ordinary regular file")
134
+
135
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
136
+ try:
137
+ descriptor = os.open(callback_path, flags)
138
+ except OSError:
139
+ raise HookError("Python plugin path could not be opened safely") from None
140
+ try:
141
+ descriptor_metadata = os.fstat(descriptor)
142
+ if not stat.S_ISREG(descriptor_metadata.st_mode):
143
+ raise HookError("Python plugin path must reference an ordinary regular file")
144
+ with os.fdopen(descriptor, "rb") as source:
145
+ descriptor = -1
146
+ return callback_path, source.read()
147
+ except HookError:
148
+ raise
149
+ except OSError:
150
+ raise HookError("Python plugin source could not be read") from None
151
+ finally:
152
+ if descriptor >= 0:
153
+ os.close(descriptor)
154
+
155
+
156
+ def _open_source(root: pathlib.Path, relative_path: str) -> tuple[pathlib.Path, bytes]:
157
+ """Open and read a contained ordinary file without following parents."""
158
+
159
+ parts = tuple(relative_path.split("/"))
160
+ if _descriptor_relative_supported():
161
+ return _open_source_descriptor_relative(root, parts)
162
+ return _open_source_absolute(root, relative_path)
163
+
164
+
165
+ def _failure(stage: str, relative_path: str, callable_name: str, detail: str) -> HookError:
166
+ return HookError(
167
+ f"Python {stage} plugin {relative_path}:{callable_name} {detail}"
168
+ )
169
+
170
+
171
+ class PluginLoader:
172
+ """Load explicit repository-local callbacks once for one workflow run."""
173
+
174
+ def __init__(self) -> None:
175
+ self._cache: dict[tuple[pathlib.Path, pathlib.Path], ModuleType] = {}
176
+ self._lock = threading.RLock()
177
+ self._module_number = 0
178
+
179
+ def load(self, repo_root: pathlib.Path, reference: str) -> Callable[..., Any]:
180
+ """Return the named top-level callable from a safe source snapshot.
181
+
182
+ The lock includes descriptor open, source read, compilation, and module
183
+ execution. Thus concurrent collectors cannot execute the same source
184
+ twice. The cache key includes both canonical repository root and
185
+ canonical file path, so identical relative names in two repositories
186
+ remain independent.
187
+ """
188
+
189
+ relative_path, callable_name = _reference_parts(reference)
190
+ root = _canonical_root(repo_root)
191
+ with self._lock:
192
+ lexical_callback_path = root.joinpath(*relative_path.split("/"))
193
+ key = (root, lexical_callback_path)
194
+ module = self._cache.get(key)
195
+ if module is None:
196
+ try:
197
+ callback_path, source = _open_source(root, relative_path)
198
+ except HookError as exc:
199
+ raise HookError(
200
+ f"Python plugin {relative_path}:{callable_name} could not be loaded safely: "
201
+ f"{str(exc).removeprefix('Python plugin ')}"
202
+ ) from None
203
+ # ``callback_path`` is canonical after containment checks and
204
+ # is equal to the lexical path for a safe repository file.
205
+ key = (root, callback_path)
206
+ try:
207
+ source_text = source.decode(_SOURCE_ENCODING)
208
+ code = compile(source_text, str(callback_path), "exec")
209
+ except UnicodeDecodeError:
210
+ raise HookError(
211
+ f"Python plugin {relative_path}:{callable_name} source is not valid UTF-8"
212
+ ) from None
213
+ except (SyntaxError, ValueError, TypeError):
214
+ raise HookError(
215
+ f"Python plugin {relative_path}:{callable_name} could not be compiled"
216
+ ) from None
217
+
218
+ self._module_number += 1
219
+ digest = hashlib.sha256(
220
+ f"{root}\0{callback_path}".encode()
221
+ ).hexdigest()[:16]
222
+ module_name = f"ai_push_hooks_plugin_{digest}_{self._module_number}"
223
+ module = ModuleType(module_name)
224
+ module.__file__ = str(callback_path)
225
+ module.__package__ = ""
226
+ try:
227
+ exec(code, module.__dict__) # noqa: S102
228
+ except KeyboardInterrupt:
229
+ raise
230
+ except SystemExit:
231
+ raise HookError(
232
+ f"Python plugin {relative_path}:{callable_name} exited during import"
233
+ ) from None
234
+ except ModuleNotFoundError as exc:
235
+ if exc.name:
236
+ detail = f"is missing dependency {exc.name!r}"
237
+ else:
238
+ detail = "could not import a dependency"
239
+ raise HookError(
240
+ f"Python plugin {relative_path}:{callable_name} {detail}"
241
+ ) from None
242
+ except ImportError:
243
+ raise HookError(
244
+ f"Python plugin {relative_path}:{callable_name} could not import a dependency"
245
+ ) from None
246
+ except Exception: # noqa: BLE001
247
+ raise HookError(
248
+ f"Python plugin {relative_path}:{callable_name} failed during import"
249
+ ) from None
250
+ self._cache[key] = module
251
+
252
+ try:
253
+ callback = getattr(module, callable_name)
254
+ except AttributeError:
255
+ raise _failure(
256
+ "callback", relative_path, callable_name, "was not defined"
257
+ ) from None
258
+ if not callable(callback):
259
+ raise _failure(
260
+ "callback", relative_path, callable_name, "is not callable"
261
+ ) from None
262
+ if inspect.iscoroutinefunction(callback):
263
+ raise _failure(
264
+ "callback", relative_path, callable_name, "must be synchronous"
265
+ ) from None
266
+ return callback
267
+
268
+ def invoke(
269
+ self,
270
+ repo_root: pathlib.Path,
271
+ reference: str,
272
+ context: PluginContext,
273
+ *,
274
+ stage: str = "workflow",
275
+ ) -> Any:
276
+ """Load lazily and invoke one callback with exactly one context argument."""
277
+
278
+ relative_path, callable_name = _reference_parts(reference)
279
+ try:
280
+ callback = self.load(repo_root, reference)
281
+ except HookError as exc:
282
+ message = str(exc)
283
+ if message.startswith("Python plugin "):
284
+ message = message.replace(
285
+ "Python plugin ", f"Python {stage} plugin ", 1
286
+ )
287
+ elif message.startswith("Python callback plugin "):
288
+ message = message.replace(
289
+ "Python callback plugin ", f"Python {stage} plugin ", 1
290
+ )
291
+ raise HookError(message) from None
292
+
293
+ try:
294
+ value = callback(context)
295
+ except KeyboardInterrupt:
296
+ raise
297
+ except SystemExit:
298
+ raise _failure(stage, relative_path, callable_name, "exited") from None
299
+ except Exception: # noqa: BLE001
300
+ raise _failure(stage, relative_path, callable_name, "raised an exception") from None
301
+
302
+ if inspect.isawaitable(value):
303
+ close = getattr(value, "close", None)
304
+ if callable(close):
305
+ close()
306
+ raise _failure(stage, relative_path, callable_name, "must return synchronously")
307
+ return value
308
+
309
+ # Explicit aliases make the intended internal seam easy to integrate while
310
+ # retaining one implementation and one cache.
311
+ load_callback = load
312
+ invoke_callback = invoke
313
+
314
+
315
+ def _ordered_inputs(
316
+ step: StepConfig, input_paths: Mapping[str, pathlib.Path] | Sequence[pathlib.Path]
317
+ ) -> dict[str, pathlib.Path]:
318
+ if isinstance(input_paths, Mapping):
319
+ declared = set(step.inputs)
320
+ extra = [reference for reference in input_paths if reference not in declared]
321
+ if extra:
322
+ raise HookError(f"Undeclared Python plugin input: {extra[0]}")
323
+ missing = [reference for reference in step.inputs if reference not in input_paths]
324
+ if missing:
325
+ raise HookError(f"Missing resolved Python plugin input: {missing[0]}")
326
+ return {reference: input_paths[reference] for reference in step.inputs}
327
+ if len(input_paths) != len(step.inputs):
328
+ raise HookError("Resolved Python plugin inputs do not match declared inputs")
329
+ return dict(zip(step.inputs, input_paths))
330
+
331
+
332
+ def build_plugin_context(
333
+ runtime: RuntimeContext,
334
+ state: ModuleRuntimeState,
335
+ step: StepConfig,
336
+ input_paths: Mapping[str, pathlib.Path] | Sequence[pathlib.Path],
337
+ ) -> PluginContext:
338
+ """Create the immutable callback snapshot from runtime state at invocation."""
339
+
340
+ cache = runtime.cache
341
+ branch_name = str(cache.get("branch_name", ""))
342
+ checked_out_branch = str(cache.get("checked_out_branch", branch_name))
343
+ ranges = cache.get("ranges", cache.get("branch_ranges", ()))
344
+ changed_files = cache.get("changed_files", cache.get("branch_changed_files", ()))
345
+ diff_text = str(cache.get("diff_text", cache.get("branch_diff_text", "")))
346
+ push_updates = cache.get("push_updates", cache.get("pushed_branch_updates", ()))
347
+ push = PushContext(
348
+ branch_name=branch_name,
349
+ checked_out_branch=checked_out_branch,
350
+ base_branch=runtime.config.general.base_branch,
351
+ ranges=tuple(ranges),
352
+ changed_files=tuple(changed_files),
353
+ diff_text=diff_text,
354
+ push_updates=tuple(push_updates),
355
+ )
356
+ return PluginContext(
357
+ repo_root=_canonical_root(runtime.repo_root),
358
+ module_id=state.module.id,
359
+ step_id=step.id,
360
+ inputs=_ordered_inputs(step, input_paths),
361
+ options=copy.deepcopy(step.options),
362
+ prior_module_metadata=copy.deepcopy(state.metadata),
363
+ push=push,
364
+ logger=runtime.logger,
365
+ )
366
+
367
+
368
+ class PluginDispatcher:
369
+ """Dispatch configured Python steps without changing engine scheduling."""
370
+
371
+ def __init__(self, loader: PluginLoader | None = None) -> None:
372
+ self.loader = loader or PluginLoader()
373
+
374
+ def dispatch(
375
+ self,
376
+ runtime: RuntimeContext,
377
+ state: ModuleRuntimeState,
378
+ step: StepConfig,
379
+ input_paths: Mapping[str, pathlib.Path] | Sequence[pathlib.Path],
380
+ ) -> Any:
381
+ if not step.python:
382
+ raise HookError(f"Python {step.type} step `{step.id}` has no callback reference")
383
+ context = build_plugin_context(runtime, state, step, input_paths)
384
+ return self.loader.invoke(
385
+ runtime.repo_root,
386
+ step.python,
387
+ context,
388
+ stage=step.type,
389
+ )
390
+
391
+ invoke = dispatch
392
+
393
+
394
+ __all__ = [
395
+ "PluginDispatcher",
396
+ "PluginLoader",
397
+ "build_plugin_context",
398
+ ]
@@ -0,0 +1,134 @@
1
+ """Public, deliberately small contracts for repository-local workflow callbacks.
2
+
3
+ This module is a contract surface, not a plugin SDK. Callback code should only
4
+ depend on the three records exported here; loading and dispatch remain host
5
+ responsibilities.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import pathlib
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from types import MappingProxyType
16
+ from typing import Any
17
+
18
+ from .paths import validate_path_component
19
+ from .types import CollectorResult, HookError, HookLogger, PushRefUpdate
20
+
21
+ __all__ = ["CollectorResult", "PluginContext", "PushContext"]
22
+
23
+
24
+ def _freeze(value: Any) -> Any:
25
+ """Copy a supported snapshot value into immutable public containers."""
26
+
27
+ if isinstance(value, Mapping):
28
+ return MappingProxyType({key: _freeze(item) for key, item in value.items()})
29
+ if isinstance(value, (list, tuple)):
30
+ return tuple(_freeze(item) for item in value)
31
+ if isinstance(value, set):
32
+ return frozenset(_freeze(item) for item in value)
33
+ return copy.deepcopy(value)
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class PushContext:
38
+ """The bounded push facts made available to a configured callback."""
39
+
40
+ branch_name: str
41
+ checked_out_branch: str
42
+ base_branch: str
43
+ ranges: tuple[str, ...]
44
+ changed_files: tuple[str, ...]
45
+ diff_text: str
46
+ push_updates: tuple[PushRefUpdate, ...]
47
+
48
+ def __post_init__(self) -> None:
49
+ object.__setattr__(self, "ranges", tuple(self.ranges))
50
+ object.__setattr__(self, "changed_files", tuple(self.changed_files))
51
+ object.__setattr__(self, "push_updates", tuple(self.push_updates))
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class PluginContext:
56
+ """Frozen, flat input passed as the sole argument to a callback."""
57
+
58
+ repo_root: pathlib.Path
59
+ module_id: str
60
+ step_id: str
61
+ inputs: Mapping[str, pathlib.Path]
62
+ options: Mapping[str, Any]
63
+ prior_module_metadata: Mapping[str, Any]
64
+ push: PushContext
65
+ logger: HookLogger
66
+
67
+ def __post_init__(self) -> None:
68
+ object.__setattr__(self, "inputs", MappingProxyType(dict(self.inputs)))
69
+ object.__setattr__(self, "options", _freeze(self.options))
70
+ object.__setattr__(self, "prior_module_metadata", _freeze(self.prior_module_metadata))
71
+
72
+
73
+ def _require_json(value: Any, label: str) -> None:
74
+ try:
75
+ json.dumps(value, allow_nan=False)
76
+ except (RecursionError, TypeError, ValueError) as exc:
77
+ raise HookError(f"{label} must be JSON-serializable") from exc
78
+ pending = [(value, 0)]
79
+ while pending:
80
+ current, depth = pending.pop()
81
+ if depth > 1000:
82
+ raise HookError(f"{label} exceeds the maximum JSON nesting depth")
83
+ if isinstance(current, dict):
84
+ pending.extend((item, depth + 1) for item in current.values())
85
+ elif isinstance(current, list):
86
+ pending.extend((item, depth + 1) for item in current)
87
+
88
+
89
+ def validate_collector_result(value: Any) -> CollectorResult:
90
+ """Validate and return a callback's collector result without mutating it."""
91
+
92
+ if not isinstance(value, CollectorResult):
93
+ raise HookError("Python collect callback must return CollectorResult")
94
+ if not isinstance(value.artifacts, dict):
95
+ raise HookError("CollectorResult.artifacts must be a mapping")
96
+ if type(value.skip_module) is not bool:
97
+ raise HookError("CollectorResult.skip_module must be a boolean")
98
+ if not isinstance(value.skip_reason, str):
99
+ raise HookError("CollectorResult.skip_reason must be a string")
100
+ if not isinstance(value.metadata, dict):
101
+ raise HookError("CollectorResult.metadata must be a mapping")
102
+ _require_json(value.metadata, "CollectorResult.metadata")
103
+ for name, payload in value.artifacts.items():
104
+ if not isinstance(name, str):
105
+ raise HookError("CollectorResult artifact names must be strings")
106
+ validate_path_component(name, "CollectorResult artifact name")
107
+ if not isinstance(payload, (str, dict, list)):
108
+ raise HookError(
109
+ f"CollectorResult.artifacts[{name!r}] must be a string, object, or array"
110
+ )
111
+ _require_json(payload, f"CollectorResult artifact {name!r}")
112
+ return value
113
+
114
+
115
+ def validate_exec_result(value: Any) -> dict[str, Any]:
116
+ """Validate the JSON object returned by a Python exec callback."""
117
+
118
+ if not isinstance(value, dict):
119
+ raise HookError("Python exec callback must return a dict")
120
+ _require_json(value, "Python exec callback result")
121
+ return value
122
+
123
+
124
+ def validate_assert_result(value: Any) -> dict[str, Any]:
125
+ """Validate the JSON object and strict boolean verdict from an assert callback."""
126
+
127
+ if not isinstance(value, dict):
128
+ raise HookError("Python assert callback must return a dict")
129
+ if "ok" not in value or type(value["ok"]) is not bool:
130
+ raise HookError("Python assert callback result.ok must be a boolean")
131
+ if "message" in value and not isinstance(value["message"], str):
132
+ raise HookError("Python assert callback result.message must be a string")
133
+ _require_json(value, "Python assert callback result")
134
+ return value
@@ -71,6 +71,11 @@ skip_on_sync_branch = true
71
71
  base_branch = "main"
72
72
 
73
73
  [llm]
74
+ # Compatibility default: OpenCode receives validated hook artifacts only.
75
+ # Define [runners.<name>] and select it with [llm].runner, or override an
76
+ # individual ask/apply step with runner = "<name>". Project access is explicit.
77
+ # Repository callbacks use python = "path/to/file.py:callable" and command
78
+ # steps use a direct argv array; both are trusted local code.
74
79
  runner = "opencode"
75
80
  model = "openai/gpt-5.6-terra"
76
81
  variant = ""
@@ -82,6 +87,8 @@ json_retry_new_session = true
82
87
  delete_session_after_run = true
83
88
 
84
89
  [logging]
90
+ # Transcripts are OpenCode-only lifecycle exports; other runners are ephemeral
91
+ # or command-owned. print_llm_output, when enabled, is normalized/redacted text.
85
92
  level = "status"
86
93
  jsonl = true
87
94
  dir = ".git/ai-push-hooks/logs"
@@ -102,7 +109,7 @@ collector = "docs_context"
102
109
 
103
110
  [[modules.docs.steps]]
104
111
  id = "query"
105
- type = "llm"
112
+ type = "ask"
106
113
  prompt = """
107
114
  Given the attached diff and changed file list, output a JSON array of concise
108
115
  documentation search queries. Return JSON only.
@@ -113,7 +120,7 @@ schema = "string_array"
113
120
 
114
121
  [[modules.docs.steps]]
115
122
  id = "analyze"
116
- type = "llm"
123
+ type = "ask"
117
124
  prompt = """
118
125
  Review the diff and matched docs excerpts. Return JSON issues only for factual
119
126
  documentation drift caused by the code changes.