ai-push-hooks 0.2.0 → 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.
- package/CHANGELOG.md +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
|
@@ -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 = "
|
|
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 = "
|
|
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.
|