ai-push-hooks 0.3.3 → 0.3.4
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 +32 -1
- package/README.md +2 -2
- package/SECURITY.md +13 -3
- package/bin/ai-push-hooks.js +13 -11
- package/bin/bootstrap.py +69 -0
- package/docs/configuration.md +5 -3
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/ai_push_hooks/cli.py +2 -1
- package/src/ai_push_hooks/executors/apply.py +27 -10
- package/src/ai_push_hooks/executors/ask.py +10 -4
- package/src/ai_push_hooks/executors/assertions.py +5 -2
- package/src/ai_push_hooks/executors/exec.py +5 -7
- package/src/ai_push_hooks/executors/runners/contracts.py +75 -17
- package/src/ai_push_hooks/executors/step_commands.py +101 -63
- package/src/ai_push_hooks/git_utils.py +13 -2
- package/src/ai_push_hooks/hook.py +5 -2
- package/src/ai_push_hooks/modules/docs.py +33 -5
- package/src/ai_push_hooks/paths.py +19 -7
- package/src/ai_push_hooks/plugin_loader.py +18 -5
- package/vendor/README.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.4] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
This is the first stable release. Python and npm both use `0.3.4`, the canonical
|
|
10
|
+
Git tag is `v0.3.4`, npm uses the `latest` dist-tag, and GitHub publishes a
|
|
11
|
+
standard release.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Failed the clean-worktree gate when Git cannot verify checkout status.
|
|
16
|
+
- Rejected apply destinations inside configured Git hooks directories before
|
|
17
|
+
propagating any changes to the checkout.
|
|
18
|
+
- Isolated npm's Python startup from consuming-repository modules, inherited
|
|
19
|
+
`PYTHONPATH`, and user-site imports so they cannot replace the installed hook.
|
|
20
|
+
- Excluded Git-ignored local documentation from default model context collection.
|
|
21
|
+
- Required explicit boolean Beads verdicts before commands, report changes, or
|
|
22
|
+
quality-gate decisions.
|
|
23
|
+
- Kept failed command output in private artifacts rather than terminal errors.
|
|
24
|
+
- Redacted known sensitive values across diagnostic truncation and partial-output
|
|
25
|
+
boundaries, including timeout errors.
|
|
26
|
+
- Collected review diffs independently of external diff drivers, text conversion,
|
|
27
|
+
and color settings.
|
|
28
|
+
- Made private runtime directory creation tolerate concurrent OpenCode stages
|
|
29
|
+
while retaining directory and symlink checks.
|
|
30
|
+
- Included regular-file, symlink, and other Git type changes in pushed-file
|
|
31
|
+
facts exposed to workflows and Python callbacks.
|
|
32
|
+
- Preserved each Python callback source's annotation semantics and registered
|
|
33
|
+
callback modules for dataclasses and runtime type-hint introspection.
|
|
34
|
+
- Validated recovered Actions artifacts against their associated push run,
|
|
35
|
+
release workflow, repository, tag, and commit before downloading archives.
|
|
36
|
+
|
|
7
37
|
## [0.3.3] - 2026-09-14
|
|
8
38
|
|
|
9
39
|
This is a beta patch release. Python and npm both use `0.3.3`, the canonical
|
|
@@ -215,7 +245,8 @@ the exact `0.2.0` version rather than a PyPI beta channel.
|
|
|
215
245
|
- Preserved Git porcelain paths and protected pre-existing dirty allowlisted files during apply steps.
|
|
216
246
|
- Clarified module-local artifact references and standardized the repository hook integration.
|
|
217
247
|
|
|
218
|
-
[Unreleased]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.
|
|
248
|
+
[Unreleased]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.4...HEAD
|
|
249
|
+
[0.3.4]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.3...v0.3.4
|
|
219
250
|
[0.3.3]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.2...v0.3.3
|
|
220
251
|
[0.3.2]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.1...v0.3.2
|
|
221
252
|
[0.3.1]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.0...v0.3.1
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ai-push-hooks
|
|
2
2
|
|
|
3
|
-
[](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml) [](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml) [](https://www.npmjs.com/package/ai-push-hooks)
|
|
4
4
|
|
|
5
5
|
**Agentic linting for the rules your coding agent forgot.**
|
|
6
6
|
|
|
@@ -30,7 +30,7 @@ Use it alongside [Lefthook](https://github.com/evilmartians/lefthook) for hook m
|
|
|
30
30
|
Install the [ai-push-hooks skill](https://github.com/shanebishop1/ai-push-hooks/blob/main/skills/ai-push-hooks/SKILL.md), tell your agent what intelligent checks you want before pushes, and let it set up the modules for you.
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
npm install --save-dev ai-push-hooks
|
|
33
|
+
npm install --save-dev ai-push-hooks
|
|
34
34
|
npx --no-install ai-push-hooks init
|
|
35
35
|
npx --no-install ai-push-hooks install
|
|
36
36
|
```
|
package/SECURITY.md
CHANGED
|
@@ -40,6 +40,14 @@ transcript files as proof that provider-side data was deleted.
|
|
|
40
40
|
|
|
41
41
|
## Repository callbacks and commands
|
|
42
42
|
|
|
43
|
+
The npm launcher uses Python isolated mode to prevent the consuming checkout,
|
|
44
|
+
inherited `PYTHONPATH`, and the user site directory from shadowing the shipped
|
|
45
|
+
launcher or standard-library modules. It retains the selected interpreter's
|
|
46
|
+
normal system/virtual-environment site-packages for callback dependencies; use
|
|
47
|
+
that environment rather than `--user` installs or `PYTHONPATH`. This is launch
|
|
48
|
+
path isolation, not an operating-system sandbox, and trusted interpreter
|
|
49
|
+
startup customization or callback code is not made safe by it.
|
|
50
|
+
|
|
43
51
|
The published `0.3.0` beta includes the `ask` spelling, repository Python
|
|
44
52
|
callbacks, and direct `exec`/`assert` commands. The previous `0.2.1` beta used
|
|
45
53
|
`llm` for model-backed workflow steps; there is no compatibility alias, so
|
|
@@ -70,9 +78,11 @@ default command timeout is 60 seconds. stdout/stderr are private, unredacted
|
|
|
70
78
|
is capped at 16 MiB; invalid UTF-8, timeout, signal, missing executable, or
|
|
71
79
|
truncation fails closed. A command may explicitly invoke `bash -c`, and
|
|
72
80
|
`exec`/`assert` commands may modify the real checkout, so these are user-policy
|
|
73
|
-
choices rather than host isolation guarantees.
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
choices rather than host isolation guarantees. Failed command diagnostics name
|
|
82
|
+
the step, status/error class, and private artifact namespace only; they never
|
|
83
|
+
include captured stream text. `assert` saves its report before blocking on a
|
|
84
|
+
false/nonzero result. Only the workflow-level fail-open setting overrides that
|
|
85
|
+
block; there is no per-command override.
|
|
76
86
|
|
|
77
87
|
Read-only `collect` callback work may overlap up to `max_parallel`; trusted
|
|
78
88
|
callback/command authors must provide their own concurrency safety. `exec` and
|
package/bin/ai-push-hooks.js
CHANGED
|
@@ -5,17 +5,22 @@ const fs = require('node:fs');
|
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
|
|
7
7
|
const packageRoot = path.resolve(__dirname, '..');
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const args = ['-
|
|
8
|
+
const bootstrap = path.join(packageRoot, 'bin', 'bootstrap.py');
|
|
9
|
+
const capabilityEnvironment = 'AI_PUSH_HOOKS_INTERNAL_CAPABILITY';
|
|
10
|
+
const args = ['-I', bootstrap, ...process.argv.slice(2)];
|
|
11
11
|
const pythonCommands = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3', 'python'];
|
|
12
12
|
|
|
13
|
-
function buildEnv() {
|
|
13
|
+
function buildEnv(capability = false) {
|
|
14
14
|
const env = { ...process.env };
|
|
15
15
|
env.AI_PUSH_HOOKS_NODE_EXECUTABLE = process.execPath;
|
|
16
16
|
env.AI_PUSH_HOOKS_NODE_SCRIPT = fs.realpathSync(__filename);
|
|
17
|
-
//
|
|
18
|
-
|
|
17
|
+
// -I intentionally ignores PYTHONPATH. The bootstrap adds only shipped paths.
|
|
18
|
+
delete env.PYTHONPATH;
|
|
19
|
+
if (capability) {
|
|
20
|
+
env[capabilityEnvironment] = '1';
|
|
21
|
+
} else {
|
|
22
|
+
delete env[capabilityEnvironment];
|
|
23
|
+
}
|
|
19
24
|
return env;
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -29,11 +34,8 @@ function run(command) {
|
|
|
29
34
|
function canRunPackage(command) {
|
|
30
35
|
const check = spawnSync(
|
|
31
36
|
command,
|
|
32
|
-
[
|
|
33
|
-
|
|
34
|
-
'import sys; assert sys.version_info >= (3, 10); __import__("tomllib" if sys.version_info >= (3, 11) else "tomli")',
|
|
35
|
-
],
|
|
36
|
-
{ stdio: 'ignore', env: buildEnv() },
|
|
37
|
+
['-I', bootstrap],
|
|
38
|
+
{ stdio: 'ignore', env: buildEnv(true) },
|
|
37
39
|
);
|
|
38
40
|
return check.status === 0;
|
|
39
41
|
}
|
package/bin/bootstrap.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Isolated entry point for the npm launcher.
|
|
2
|
+
|
|
3
|
+
The npm package is not a Python installation. This file adds only the Python
|
|
4
|
+
sources and the vendored Python 3.10 TOML dependency shipped beside it, while
|
|
5
|
+
leaving the selected interpreter's regular site-packages available for user
|
|
6
|
+
callbacks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import runpy
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_PACKAGE_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
18
|
+
_SHIPPED_SOURCE = _PACKAGE_ROOT / "src"
|
|
19
|
+
_SHIPPED_TOMLI = _PACKAGE_ROOT / "vendor" / "tomli-2.4.0-py3-none-any.whl"
|
|
20
|
+
_CAPABILITY_ENVIRONMENT = "AI_PUSH_HOOKS_INTERNAL_CAPABILITY"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _without_working_directory(entries: list[str]) -> list[str]:
|
|
24
|
+
working_directory = os.path.realpath(os.getcwd())
|
|
25
|
+
safe_entries: list[str] = []
|
|
26
|
+
for entry in entries:
|
|
27
|
+
if not entry or not os.path.isabs(entry):
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
if os.path.realpath(entry) == working_directory:
|
|
31
|
+
continue
|
|
32
|
+
except OSError:
|
|
33
|
+
continue
|
|
34
|
+
safe_entries.append(entry)
|
|
35
|
+
return safe_entries
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _configure_imports() -> None:
|
|
39
|
+
"""Retain interpreter site-packages and add only shipped import paths."""
|
|
40
|
+
sys.path[:] = _without_working_directory(sys.path)
|
|
41
|
+
sys.path.insert(0, str(_SHIPPED_TOMLI))
|
|
42
|
+
sys.path.insert(0, str(_SHIPPED_SOURCE))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _has_supported_toml() -> bool:
|
|
46
|
+
if sys.version_info < (3, 10):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
if sys.version_info >= (3, 11):
|
|
50
|
+
import tomllib # noqa: F401
|
|
51
|
+
else:
|
|
52
|
+
import tomli # noqa: F401
|
|
53
|
+
except ImportError:
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
_configure_imports()
|
|
60
|
+
if not _has_supported_toml():
|
|
61
|
+
return 1
|
|
62
|
+
if os.environ.get(_CAPABILITY_ENVIRONMENT) == "1":
|
|
63
|
+
return 0
|
|
64
|
+
runpy.run_module("ai_push_hooks", run_name="__main__")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
raise SystemExit(main())
|
package/docs/configuration.md
CHANGED
|
@@ -4,12 +4,14 @@ Define workflows in `ai-push-hooks.toml` at the repository root. Start with the
|
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
|
-
The [npm quick start](../README.md#quick-start) installs a repository-local wrapper.
|
|
7
|
+
The [npm quick start](../README.md#quick-start) installs a repository-local wrapper. Its tested/supported Python range is 3.10-3.13; the Python code requires 3.10+ and the launcher may probe newer interpreters, but newer versions are not part of that compatibility claim. The npm package does not bundle Python. The wrapper starts the selected interpreter in Python isolated mode: it ignores `PYTHONPATH` and the user site directory, then adds only the package's shipped `src` and vendored Tomli wheel. The interpreter's normal system or virtual-environment site-packages remain available.
|
|
8
|
+
|
|
9
|
+
If a repository callback needs an external dependency, install it with the exact Python executable the wrapper will select (for example, `/path/to/python -m pip install <dependency>`), into that interpreter's normal system or virtual-environment site-packages. The wrapper tests candidates in its ordered list (`python3.14`, `python3.13`, `python3.12`, `python3.11`, `python3.10`, `python3`, then `python`), so activating a virtual environment alone does not guarantee that it wins over a higher-priority executable elsewhere on `PATH`; make the intended candidate discoverable first. Do not rely on `pip install --user` or `PYTHONPATH`; they are intentionally not part of the npm launch path.
|
|
8
10
|
|
|
9
11
|
For pnpm:
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
|
-
pnpm add -D ai-push-hooks
|
|
14
|
+
pnpm add -D ai-push-hooks
|
|
13
15
|
pnpm exec ai-push-hooks init --template minimal-docs
|
|
14
16
|
pnpm exec ai-push-hooks install
|
|
15
17
|
```
|
|
@@ -22,7 +24,7 @@ ai-push-hooks init --template minimal-docs
|
|
|
22
24
|
ai-push-hooks install
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
`uv tool install ai-push-hooks` or `pipx install ai-push-hooks` can replace the pip command.
|
|
27
|
+
`uv tool install ai-push-hooks` or `pipx install ai-push-hooks` can replace the pip command.
|
|
26
28
|
|
|
27
29
|
AI steps need the selected CLI installed and authenticated with an available model. Deterministic-only workflows do not need one. `init` and `install` refuse to overwrite existing files; `--force` explicitly replaces them.
|
|
28
30
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ai-push-hooks"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Run structured AI-assisted checks and allowlisted maintenance before git push"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -12,7 +12,7 @@ license = "MIT"
|
|
|
12
12
|
authors = [{ name = "Shane Bishop" }]
|
|
13
13
|
keywords = ["git", "lefthook", "docs", "ai", "pre-push"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
18
|
"Programming Language :: Python :: 3 :: Only",
|
package/src/ai_push_hooks/cli.py
CHANGED
|
@@ -10,6 +10,7 @@ from .hook import run_hook
|
|
|
10
10
|
from .install import install_hook
|
|
11
11
|
from .paths import path_is_link_or_reparse, write_text_no_follow
|
|
12
12
|
from .prompts_builtin import MINIMAL_DOCS_TEMPLATE
|
|
13
|
+
from .executors.runners import RunnerError
|
|
13
14
|
from .types import HookError
|
|
14
15
|
|
|
15
16
|
|
|
@@ -117,6 +118,6 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
117
118
|
if args.command == "install":
|
|
118
119
|
return install_hook(args.force)
|
|
119
120
|
raise HookError(f"Unknown command: {args.command}")
|
|
120
|
-
except HookError as exc:
|
|
121
|
+
except (HookError, RunnerError) as exc:
|
|
121
122
|
sys.stderr.write(f"[ai-push-hooks] {exc}\n")
|
|
122
123
|
return 1
|
|
@@ -213,6 +213,20 @@ def _runtime_metadata_namespaces(context: RuntimeContext) -> tuple[pathlib.Path,
|
|
|
213
213
|
return namespaces
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
def _resolve_effective_hooks_path(context: RuntimeContext) -> pathlib.Path:
|
|
217
|
+
raw_hooks_path = run_command(
|
|
218
|
+
["git", "rev-parse", "--git-path", "hooks"],
|
|
219
|
+
cwd=context.repo_root,
|
|
220
|
+
check=True,
|
|
221
|
+
).stdout.strip()
|
|
222
|
+
if not raw_hooks_path:
|
|
223
|
+
raise HookError("Git did not return an effective hooks path")
|
|
224
|
+
hooks_path = pathlib.Path(raw_hooks_path)
|
|
225
|
+
if not hooks_path.is_absolute():
|
|
226
|
+
hooks_path = context.repo_root / hooks_path
|
|
227
|
+
return pathlib.Path(os.path.abspath(hooks_path))
|
|
228
|
+
|
|
229
|
+
|
|
216
230
|
def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
|
|
217
231
|
git_dir = context.git_dir.resolve(strict=True)
|
|
218
232
|
common_dir = resolve_git_common_dir(context.repo_root).resolve(strict=True)
|
|
@@ -323,15 +337,7 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
|
|
|
323
337
|
for name in ("HEAD", "config", "config.worktree", "packed-refs"):
|
|
324
338
|
record(f"shared:{name}", common_dir / name)
|
|
325
339
|
scan_tree("shared:refs", common_dir / "refs")
|
|
326
|
-
|
|
327
|
-
["git", "rev-parse", "--git-path", "hooks"],
|
|
328
|
-
cwd=context.repo_root,
|
|
329
|
-
check=True,
|
|
330
|
-
).stdout.strip()
|
|
331
|
-
hooks_path = pathlib.Path(raw_hooks_path)
|
|
332
|
-
if not hooks_path.is_absolute():
|
|
333
|
-
hooks_path = context.repo_root / hooks_path
|
|
334
|
-
hooks_path = pathlib.Path(os.path.abspath(hooks_path))
|
|
340
|
+
hooks_path = _resolve_effective_hooks_path(context)
|
|
335
341
|
if any(is_path_within(hooks_path, namespace) for namespace in excluded_namespaces):
|
|
336
342
|
raise HookError(
|
|
337
343
|
"Configured core.hooksPath must not overlap ai-push-hooks runtime metadata: "
|
|
@@ -582,6 +588,12 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
|
|
|
582
588
|
raise HookError(
|
|
583
589
|
f"Apply destination resolves inside Git metadata: {relative_path}"
|
|
584
590
|
)
|
|
591
|
+
hooks_path = _resolve_effective_hooks_path(context).resolve(strict=False)
|
|
592
|
+
if is_path_within(resolved_destination, hooks_path):
|
|
593
|
+
raise HookError(
|
|
594
|
+
f"Apply destination resolves inside configured Git hooks path: "
|
|
595
|
+
f"{relative_path}"
|
|
596
|
+
)
|
|
585
597
|
return destination
|
|
586
598
|
|
|
587
599
|
|
|
@@ -602,6 +614,12 @@ def _preflight_apply_operations(
|
|
|
602
614
|
context: RuntimeContext,
|
|
603
615
|
operations: list[ApplyOperation],
|
|
604
616
|
) -> None:
|
|
617
|
+
# Validate every destination before the propagation loop can create a
|
|
618
|
+
# parent directory or write even the first operation. In particular, a
|
|
619
|
+
# configured core.hooksPath may not exist yet, so this guard cannot depend
|
|
620
|
+
# on the hooks tree being present in the checkout.
|
|
621
|
+
for operation in operations:
|
|
622
|
+
_safe_destination(context, operation.relative_path)
|
|
605
623
|
conflicts = [
|
|
606
624
|
operation.relative_path
|
|
607
625
|
for operation in operations
|
|
@@ -647,7 +665,6 @@ def _propagate_staging_changes(
|
|
|
647
665
|
operations: list[ApplyOperation] = []
|
|
648
666
|
expected: dict[str, StagedFile | None] = {}
|
|
649
667
|
for relative_path in sorted(changed_paths):
|
|
650
|
-
_safe_destination(context, relative_path)
|
|
651
668
|
baseline = baselines.get(relative_path, DestinationState("missing"))
|
|
652
669
|
staged = after.get(relative_path)
|
|
653
670
|
if staged is None:
|
|
@@ -63,14 +63,20 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
63
63
|
if schema == "beads_alignment_result":
|
|
64
64
|
if not isinstance(payload, dict):
|
|
65
65
|
raise HookError("Expected schema beads_alignment_result")
|
|
66
|
-
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
unresolved = payload.get("unresolved")
|
|
67
|
+
if type(unresolved) is not bool:
|
|
68
|
+
raise HookError("beads_alignment_result.unresolved must be a boolean")
|
|
69
|
+
if "commands" in payload and (
|
|
70
|
+
not isinstance(payload["commands"], list)
|
|
71
|
+
or not all(isinstance(item, str) for item in payload["commands"])
|
|
70
72
|
):
|
|
71
73
|
raise HookError(
|
|
72
74
|
"beads_alignment_result.commands must be an array of strings"
|
|
73
75
|
)
|
|
76
|
+
if "report_markdown" in payload and not isinstance(
|
|
77
|
+
payload["report_markdown"], str
|
|
78
|
+
):
|
|
79
|
+
raise HookError("beads_alignment_result.report_markdown must be a string")
|
|
74
80
|
return payload
|
|
75
81
|
if schema == "pr_create_payload":
|
|
76
82
|
if not isinstance(payload, dict):
|
|
@@ -5,6 +5,7 @@ import pathlib
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from ..types import RuntimeContext, StepConfig
|
|
8
|
+
from .ask import validate_schema
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def docs_apply_requires_manual_commit(
|
|
@@ -28,8 +29,10 @@ def beads_alignment_clean(
|
|
|
28
29
|
_step: StepConfig,
|
|
29
30
|
inputs: list[pathlib.Path],
|
|
30
31
|
) -> dict[str, Any]:
|
|
31
|
-
payload =
|
|
32
|
-
|
|
32
|
+
payload = validate_schema(
|
|
33
|
+
"beads_alignment_result", json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
34
|
+
)
|
|
35
|
+
unresolved = payload["unresolved"]
|
|
33
36
|
if unresolved:
|
|
34
37
|
return {
|
|
35
38
|
"ok": False,
|
|
@@ -209,12 +209,10 @@ def beads_alignment_executor(
|
|
|
209
209
|
"report_written": False,
|
|
210
210
|
"unresolved": False,
|
|
211
211
|
}
|
|
212
|
-
payload =
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
payload = validate_schema(
|
|
213
|
+
"beads_alignment_result", json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
214
|
+
)
|
|
215
215
|
commands = payload.get("commands", [])
|
|
216
|
-
if not isinstance(commands, list):
|
|
217
|
-
raise HookError("beads_alignment commands must be an array")
|
|
218
216
|
if len(commands) > BEADS_ALIGNMENT_MAX_COMMANDS:
|
|
219
217
|
raise HookError(
|
|
220
218
|
f"beads_alignment accepts at most {BEADS_ALIGNMENT_MAX_COMMANDS} commands"
|
|
@@ -245,8 +243,8 @@ def beads_alignment_executor(
|
|
|
245
243
|
)
|
|
246
244
|
commands_run.append(command)
|
|
247
245
|
|
|
248
|
-
report_markdown =
|
|
249
|
-
unresolved =
|
|
246
|
+
report_markdown = payload.get("report_markdown", "").strip()
|
|
247
|
+
unresolved = payload["unresolved"]
|
|
250
248
|
report_written = False
|
|
251
249
|
if report_markdown:
|
|
252
250
|
if not report_markdown.endswith("\n"):
|
|
@@ -403,25 +403,15 @@ def strip_terminal_controls(value: str) -> str:
|
|
|
403
403
|
return _UNSAFE_TERMINAL_CONTROL_PATTERN.sub("", without_ansi)
|
|
404
404
|
|
|
405
405
|
|
|
406
|
-
def
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
redacted = strip_terminal_controls(value)
|
|
410
|
-
for pattern in _SECRET_PATTERNS:
|
|
411
|
-
redacted = pattern.sub(
|
|
412
|
-
lambda match: (
|
|
413
|
-
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
|
|
414
|
-
),
|
|
415
|
-
redacted,
|
|
416
|
-
)
|
|
417
|
-
redaction_values: list[str] = []
|
|
406
|
+
def _diagnostic_redaction_values(secrets: Sequence[str]) -> tuple[str, ...]:
|
|
407
|
+
values: list[str] = []
|
|
418
408
|
seen: set[str] = set()
|
|
419
409
|
fragment_count = 0
|
|
420
410
|
for secret in secrets:
|
|
421
411
|
if not isinstance(secret, str) or not secret or secret in seen:
|
|
422
412
|
continue
|
|
423
413
|
seen.add(secret)
|
|
424
|
-
|
|
414
|
+
values.append(secret)
|
|
425
415
|
# A bounded fragment set catches a child echoing one prompt token,
|
|
426
416
|
# while skipping a whole large diff avoids the old O(words * stream)
|
|
427
417
|
# behavior. The request-sensitive helper additionally suppresses
|
|
@@ -430,12 +420,72 @@ def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
|
|
|
430
420
|
for fragment in _DIAGNOSTIC_FRAGMENT_PATTERN.findall(secret):
|
|
431
421
|
if fragment not in seen:
|
|
432
422
|
seen.add(fragment)
|
|
433
|
-
|
|
423
|
+
values.append(fragment)
|
|
434
424
|
fragment_count += 1
|
|
435
425
|
if fragment_count >= 512:
|
|
436
426
|
break
|
|
437
427
|
if fragment_count >= 512:
|
|
438
428
|
continue
|
|
429
|
+
return tuple(values)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _longest_prefix_suffix(value: str, prefix: str) -> int:
|
|
433
|
+
"""Return the longest suffix of ``value`` that is a prefix of ``prefix``."""
|
|
434
|
+
|
|
435
|
+
limit = min(len(value), len(prefix))
|
|
436
|
+
if limit == 0:
|
|
437
|
+
return 0
|
|
438
|
+
pattern = prefix[:limit]
|
|
439
|
+
failure = [0] * limit
|
|
440
|
+
matched = 0
|
|
441
|
+
for index in range(1, limit):
|
|
442
|
+
while matched and pattern[matched] != pattern[index]:
|
|
443
|
+
matched = failure[matched - 1]
|
|
444
|
+
if pattern[matched] == pattern[index]:
|
|
445
|
+
matched += 1
|
|
446
|
+
failure[index] = matched
|
|
447
|
+
|
|
448
|
+
matched = 0
|
|
449
|
+
for character in value[-limit:]:
|
|
450
|
+
while matched and (matched == limit or pattern[matched] != character):
|
|
451
|
+
matched = failure[matched - 1]
|
|
452
|
+
if pattern[matched] == character:
|
|
453
|
+
matched += 1
|
|
454
|
+
return matched
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _boundary_redaction_start(value: str, secrets: Sequence[str]) -> int | None:
|
|
458
|
+
"""Find the earliest visible prefix of a known value at a cutoff."""
|
|
459
|
+
|
|
460
|
+
earliest: int | None = None
|
|
461
|
+
for secret in secrets:
|
|
462
|
+
if len(secret) <= 1:
|
|
463
|
+
continue
|
|
464
|
+
# The captured child stream may itself end while a known value is
|
|
465
|
+
# being emitted, so do not require the value's unseen suffix to exist
|
|
466
|
+
# in ``value``. This comparison is bounded by the preview and secret
|
|
467
|
+
# prefix, rather than scanning an unbounded stream tail.
|
|
468
|
+
prefix = secret[: min(len(value), len(secret) - 1)]
|
|
469
|
+
overlap = _longest_prefix_suffix(value, prefix)
|
|
470
|
+
if overlap:
|
|
471
|
+
start = len(value) - overlap
|
|
472
|
+
if earliest is None or start < earliest:
|
|
473
|
+
earliest = start
|
|
474
|
+
return earliest
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
|
|
478
|
+
"""Redact supplied secrets and common credential-shaped values."""
|
|
479
|
+
|
|
480
|
+
redacted = strip_terminal_controls(value)
|
|
481
|
+
for pattern in _SECRET_PATTERNS:
|
|
482
|
+
redacted = pattern.sub(
|
|
483
|
+
lambda match: (
|
|
484
|
+
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
|
|
485
|
+
),
|
|
486
|
+
redacted,
|
|
487
|
+
)
|
|
488
|
+
redaction_values = _diagnostic_redaction_values(secrets)
|
|
439
489
|
for secret in sorted(redaction_values, key=len, reverse=True):
|
|
440
490
|
redacted = redacted.replace(secret, "[REDACTED]")
|
|
441
491
|
return redacted
|
|
@@ -454,10 +504,18 @@ def bounded_diagnostic(
|
|
|
454
504
|
raw = str(value)
|
|
455
505
|
# Redact only the bounded preview. Anything after this point cannot be
|
|
456
506
|
# present in the returned diagnostic, and therefore does not need a scan.
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
|
|
507
|
+
# A known value can start in the preview and finish after it, though; mask
|
|
508
|
+
# that visible prefix after ordinary redaction so a complete match cannot
|
|
509
|
+
# be destroyed first. Boundary matching is bounded even for large
|
|
510
|
+
# streams/secrets.
|
|
511
|
+
preview = strip_terminal_controls(raw[:max_chars])
|
|
512
|
+
redaction_values = _diagnostic_redaction_values(secrets)
|
|
460
513
|
safe = redact_diagnostic(preview, secrets=secrets)
|
|
514
|
+
boundary_start = _boundary_redaction_start(safe, redaction_values)
|
|
515
|
+
if boundary_start is not None:
|
|
516
|
+
# Several known values can overlap at the cutoff. Replace their
|
|
517
|
+
# union once so insertion cannot invalidate another span's index.
|
|
518
|
+
safe = safe[:boundary_start] + "[REDACTED]"
|
|
461
519
|
if len(raw) <= max_chars and len(safe) <= max_chars:
|
|
462
520
|
return safe
|
|
463
521
|
if max_chars <= len(DIAGNOSTIC_TRUNCATION_MARKER):
|
|
@@ -9,7 +9,6 @@ engine integration lane.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import math
|
|
12
|
-
import os
|
|
13
12
|
import pathlib
|
|
14
13
|
import re
|
|
15
14
|
import sys
|
|
@@ -26,7 +25,12 @@ from ..types import (
|
|
|
26
25
|
RuntimeContext,
|
|
27
26
|
StepConfig,
|
|
28
27
|
)
|
|
29
|
-
from .runners.contracts import
|
|
28
|
+
from .runners.contracts import (
|
|
29
|
+
RunnerError,
|
|
30
|
+
RunnerExecutableNotFoundError,
|
|
31
|
+
RunnerSignalError,
|
|
32
|
+
RunnerTimeoutError,
|
|
33
|
+
)
|
|
30
34
|
from .runners.process import (
|
|
31
35
|
DEFAULT_MAX_OUTPUT_BYTES,
|
|
32
36
|
ProcessResult,
|
|
@@ -297,14 +301,26 @@ def run_step_command(
|
|
|
297
301
|
inputs=input_paths,
|
|
298
302
|
python_executable=python_executable or sys.executable,
|
|
299
303
|
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
304
|
+
try:
|
|
305
|
+
process_result = run_process(
|
|
306
|
+
argv,
|
|
307
|
+
cwd=pathlib.Path(repo_root).resolve(strict=True),
|
|
308
|
+
input_path=input_path,
|
|
309
|
+
timeout_seconds=timeout_seconds,
|
|
310
|
+
env=None,
|
|
311
|
+
max_output_bytes=max_output_bytes,
|
|
312
|
+
)
|
|
313
|
+
except RunnerError as error:
|
|
314
|
+
# Do not retain process causes: they can contain rendered argv or
|
|
315
|
+
# captured stream bytes when displayed as an exception chain.
|
|
316
|
+
error.__cause__ = None
|
|
317
|
+
error.__suppress_context__ = True
|
|
318
|
+
if isinstance(error, RunnerExecutableNotFoundError):
|
|
319
|
+
# The command vector is configuration-controlled and may contain
|
|
320
|
+
# credentials. Do not let the process layer's executable detail
|
|
321
|
+
# cross the step-command boundary.
|
|
322
|
+
error.args = ("step command executable was not found",)
|
|
323
|
+
raise
|
|
308
324
|
result = _from_process_result(process_result)
|
|
309
325
|
if result.stdout_truncated or result.stderr_truncated:
|
|
310
326
|
streams = " and ".join(
|
|
@@ -320,31 +336,31 @@ def run_step_command(
|
|
|
320
336
|
)
|
|
321
337
|
error._step_command_result = result
|
|
322
338
|
raise error
|
|
323
|
-
|
|
339
|
+
try:
|
|
340
|
+
_validate_utf8(result)
|
|
341
|
+
except StepCommandEncodingError as error:
|
|
342
|
+
error.__cause__ = None
|
|
343
|
+
error.__suppress_context__ = True
|
|
344
|
+
raise
|
|
324
345
|
return result
|
|
325
346
|
|
|
326
347
|
|
|
327
|
-
def
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
safe = redact_diagnostic(combined, secrets=_environment_secrets())
|
|
344
|
-
return (
|
|
345
|
-
bounded_diagnostic(safe, max_chars=1_200)
|
|
346
|
-
or f"command exited with status {result.returncode}"
|
|
347
|
-
)
|
|
348
|
+
def _command_failure_status(
|
|
349
|
+
error: BaseException, result: StepCommandResult | None
|
|
350
|
+
) -> str:
|
|
351
|
+
if isinstance(error, RunnerTimeoutError):
|
|
352
|
+
return "timeout"
|
|
353
|
+
if isinstance(error, RunnerSignalError):
|
|
354
|
+
return "signal"
|
|
355
|
+
if isinstance(error, StepCommandEncodingError):
|
|
356
|
+
return "invalid_utf8"
|
|
357
|
+
if isinstance(error, StepCommandTruncatedError):
|
|
358
|
+
return "capture_limit"
|
|
359
|
+
if isinstance(error, RunnerExecutableNotFoundError):
|
|
360
|
+
return "not_started"
|
|
361
|
+
if result is not None:
|
|
362
|
+
return str(result.returncode)
|
|
363
|
+
return "error"
|
|
348
364
|
|
|
349
365
|
|
|
350
366
|
def step_command_result_payload(
|
|
@@ -368,7 +384,7 @@ def step_command_result_payload(
|
|
|
368
384
|
if step_type == "assert":
|
|
369
385
|
payload["ok"] = result.returncode == 0
|
|
370
386
|
if result.returncode != 0:
|
|
371
|
-
payload["message"] =
|
|
387
|
+
payload["message"] = f"command exited with status {result.returncode}"
|
|
372
388
|
return payload
|
|
373
389
|
|
|
374
390
|
|
|
@@ -447,6 +463,7 @@ def execute_step_command(
|
|
|
447
463
|
input_map = dict(zip(step.inputs, inputs))
|
|
448
464
|
store = artifacts or ArtifactStore(context.run_dir)
|
|
449
465
|
store.prepare()
|
|
466
|
+
persisted: PersistedStepCommandResult | None = None
|
|
450
467
|
try:
|
|
451
468
|
process_result = run_step_command(
|
|
452
469
|
step.command,
|
|
@@ -459,42 +476,63 @@ def execute_step_command(
|
|
|
459
476
|
else DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS
|
|
460
477
|
),
|
|
461
478
|
)
|
|
479
|
+
payload = step_command_result_payload(process_result, step_type=step.type)
|
|
480
|
+
persisted = _persist_process_result(
|
|
481
|
+
store, state, step, process_result, payload=payload
|
|
482
|
+
)
|
|
483
|
+
if step.type == "exec" and process_result.returncode != 0:
|
|
484
|
+
error = StepCommandExecutionError(
|
|
485
|
+
"step exec command returned a non-zero status"
|
|
486
|
+
)
|
|
487
|
+
error._step_command_result = process_result
|
|
488
|
+
error._step_command_persisted = persisted
|
|
489
|
+
raise error
|
|
490
|
+
if step.type == "assert" and process_result.returncode != 0:
|
|
491
|
+
error = StepCommandAssertionError(str(payload["message"]))
|
|
492
|
+
error._step_command_result = process_result
|
|
493
|
+
error._step_command_persisted = persisted
|
|
494
|
+
raise error
|
|
462
495
|
except BaseException as error:
|
|
463
496
|
captured = _result_from_error(error)
|
|
464
497
|
if captured is None:
|
|
465
498
|
captured = getattr(error, "_step_command_result", None)
|
|
466
|
-
if isinstance(captured, StepCommandResult)
|
|
467
|
-
|
|
468
|
-
if
|
|
469
|
-
report =
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
499
|
+
captured_result = captured if isinstance(captured, StepCommandResult) else None
|
|
500
|
+
if captured_result is not None:
|
|
501
|
+
if persisted is None:
|
|
502
|
+
report = None
|
|
503
|
+
if isinstance(error, StepCommandEncodingError):
|
|
504
|
+
report = step_command_result_payload(
|
|
505
|
+
captured_result, step_type=step.type
|
|
506
|
+
)
|
|
507
|
+
report["malformed"] = True
|
|
508
|
+
report["message"] = "command output was not valid UTF-8"
|
|
509
|
+
persisted = _persist_process_result(
|
|
510
|
+
store,
|
|
511
|
+
state,
|
|
512
|
+
step,
|
|
513
|
+
captured_result,
|
|
514
|
+
payload=report,
|
|
515
|
+
)
|
|
479
516
|
error._step_command_persisted = persisted
|
|
517
|
+
if isinstance(error, (RunnerError, StepCommandError)):
|
|
518
|
+
namespace = f"{state.module.id}/{state.step_index:02d}-{step.id}"
|
|
519
|
+
run_id = getattr(context, "run_id", store.run_dir.name)
|
|
520
|
+
location_suffix = "" if persisted is not None else " (not created)"
|
|
521
|
+
returncode = (
|
|
522
|
+
f"; returncode={captured_result.returncode}"
|
|
523
|
+
if captured_result is not None
|
|
524
|
+
else ""
|
|
525
|
+
)
|
|
526
|
+
error.args = (
|
|
527
|
+
f"command step={state.module.id}.{step.id} failed; "
|
|
528
|
+
f"status={_command_failure_status(error, captured_result)}; "
|
|
529
|
+
f"error_class={error.__class__.__name__}; "
|
|
530
|
+
f"artifact_location={run_id}/{namespace}{location_suffix}"
|
|
531
|
+
f"{returncode}",
|
|
532
|
+
)
|
|
480
533
|
raise
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
persisted = _persist_process_result(
|
|
484
|
-
store, state, step, process_result, payload=payload
|
|
485
|
-
)
|
|
486
|
-
if step.type == "exec" and process_result.returncode != 0:
|
|
487
|
-
error = StepCommandExecutionError(
|
|
488
|
-
"step exec command returned a non-zero status"
|
|
489
|
-
)
|
|
490
|
-
error._step_command_result = process_result
|
|
491
|
-
error._step_command_persisted = persisted
|
|
492
|
-
raise error
|
|
493
|
-
if step.type == "assert" and process_result.returncode != 0:
|
|
494
|
-
error = StepCommandAssertionError(str(payload["message"]))
|
|
495
|
-
error._step_command_result = process_result
|
|
496
|
-
error._step_command_persisted = persisted
|
|
497
|
-
raise error
|
|
534
|
+
if persisted is None: # pragma: no cover - successful persistence is required above
|
|
535
|
+
raise StepCommandError("step command result was not persisted")
|
|
498
536
|
return persisted
|
|
499
537
|
|
|
500
538
|
|
|
@@ -543,8 +543,11 @@ def collect_changed_files(repo_root: pathlib.Path, ranges: list[str]) -> list[st
|
|
|
543
543
|
[
|
|
544
544
|
"git",
|
|
545
545
|
"diff",
|
|
546
|
+
"--no-ext-diff",
|
|
547
|
+
"--no-textconv",
|
|
548
|
+
"--no-color",
|
|
546
549
|
"--name-only",
|
|
547
|
-
"--diff-filter=
|
|
550
|
+
"--diff-filter=ACMRDT",
|
|
548
551
|
"-z",
|
|
549
552
|
range_expr,
|
|
550
553
|
],
|
|
@@ -639,7 +642,15 @@ def collect_diff(repo_root: pathlib.Path, ranges: list[str], max_bytes: int) ->
|
|
|
639
642
|
|
|
640
643
|
body, body_truncated = _collect_bounded_git_diff(
|
|
641
644
|
repo_root,
|
|
642
|
-
[
|
|
645
|
+
[
|
|
646
|
+
"git",
|
|
647
|
+
"diff",
|
|
648
|
+
"--no-ext-diff",
|
|
649
|
+
"--no-textconv",
|
|
650
|
+
"--no-color",
|
|
651
|
+
"--unified=3",
|
|
652
|
+
range_expr,
|
|
653
|
+
],
|
|
643
654
|
limit - len(output),
|
|
644
655
|
)
|
|
645
656
|
if not body_truncated:
|
|
@@ -21,11 +21,11 @@ from .git_utils import (
|
|
|
21
21
|
current_branch,
|
|
22
22
|
ensure_dir,
|
|
23
23
|
env_bool,
|
|
24
|
-
git,
|
|
25
24
|
parse_push_updates,
|
|
26
25
|
resolve_git_dir,
|
|
27
26
|
resolve_repo_root,
|
|
28
27
|
resolve_storage_path,
|
|
28
|
+
run_command,
|
|
29
29
|
should_skip_for_sync_branch,
|
|
30
30
|
unique_range_expressions,
|
|
31
31
|
)
|
|
@@ -65,7 +65,10 @@ def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def _assert_clean_worktree(repo_root: pathlib.Path) -> None:
|
|
68
|
-
|
|
68
|
+
completed = run_command(["git", "status", "--short"], cwd=repo_root, check=False)
|
|
69
|
+
if completed.returncode != 0:
|
|
70
|
+
raise HookError("Hook requires a clean worktree but Git status failed")
|
|
71
|
+
status = (completed.stdout or "").strip()
|
|
69
72
|
if status:
|
|
70
73
|
raise HookError("Hook requires a clean worktree but local changes are present")
|
|
71
74
|
|
|
@@ -9,7 +9,13 @@ from pathlib import PurePosixPath
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from ..types import CollectorResult, RuntimeContext
|
|
12
|
-
from ..git_utils import
|
|
12
|
+
from ..git_utils import (
|
|
13
|
+
collect_commit_messages_for_ranges,
|
|
14
|
+
git,
|
|
15
|
+
path_matches,
|
|
16
|
+
run_command,
|
|
17
|
+
)
|
|
18
|
+
from ..paths import path_is_link_or_reparse
|
|
13
19
|
|
|
14
20
|
DOC_INCLUDE_PATTERNS = ("README.md", "docs/**/*.md")
|
|
15
21
|
DOC_IGNORE_PATTERNS = ("docs/archive/**",)
|
|
@@ -34,7 +40,7 @@ def _is_safe_doc_file(repo_root: pathlib.Path, candidate: pathlib.Path) -> bool:
|
|
|
34
40
|
current = repo_root
|
|
35
41
|
for part in relative.parts:
|
|
36
42
|
current /= part
|
|
37
|
-
if
|
|
43
|
+
if path_is_link_or_reparse(current):
|
|
38
44
|
return False
|
|
39
45
|
candidate_stat = candidate.lstat()
|
|
40
46
|
if not stat.S_ISREG(candidate_stat.st_mode):
|
|
@@ -47,18 +53,40 @@ def _is_safe_doc_file(repo_root: pathlib.Path, candidate: pathlib.Path) -> bool:
|
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
def _expand_doc_files(repo_root: pathlib.Path) -> list[pathlib.Path]:
|
|
56
|
+
"""List safe, relevant docs from Git's tracked/unignored file inventory.
|
|
57
|
+
|
|
58
|
+
``--cached`` retains tracked files even when an ignore rule matches them;
|
|
59
|
+
``--others --exclude-standard`` adds only untracked files that Git would
|
|
60
|
+
consider visible. Enumerating this way avoids walking ignored directories
|
|
61
|
+
before any document content is opened.
|
|
62
|
+
"""
|
|
50
63
|
repo_root = repo_root.resolve(strict=True)
|
|
64
|
+
listed = run_command(
|
|
65
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
66
|
+
cwd=repo_root,
|
|
67
|
+
check=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
51
70
|
files: list[pathlib.Path] = []
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
for relative in listed.stdout.split("\x00"):
|
|
73
|
+
if not relative or relative in seen:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(relative)
|
|
54
76
|
if not _path_matches(relative, DOC_INCLUDE_PATTERNS):
|
|
55
77
|
continue
|
|
56
78
|
if _path_matches(relative, DOC_IGNORE_PATTERNS):
|
|
57
79
|
continue
|
|
80
|
+
# Git emits POSIX separators even on platforms where a backslash has
|
|
81
|
+
# path semantics. Reject such ambiguous names there rather than
|
|
82
|
+
# allowing a listed filename to change the lexical containment check.
|
|
83
|
+
if os.name == "nt" and "\\" in relative:
|
|
84
|
+
continue
|
|
85
|
+
candidate = repo_root.joinpath(*relative.split("/"))
|
|
58
86
|
if not _is_safe_doc_file(repo_root, candidate):
|
|
59
87
|
continue
|
|
60
88
|
files.append(candidate)
|
|
61
|
-
return sorted(files)
|
|
89
|
+
return sorted(files, key=lambda path: path.relative_to(repo_root).as_posix())
|
|
62
90
|
|
|
63
91
|
|
|
64
92
|
def _deterministic_seed_queries(diff_text: str, changed_files: list[str]) -> list[str]:
|
|
@@ -104,6 +104,18 @@ def resolve_contained_path(base: pathlib.Path, raw: str, label: str) -> pathlib.
|
|
|
104
104
|
return resolved_candidate
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def _ensure_private_directory_component(directory: pathlib.Path) -> None:
|
|
108
|
+
try:
|
|
109
|
+
directory.mkdir(mode=PRIVATE_DIRECTORY_MODE)
|
|
110
|
+
except FileExistsError:
|
|
111
|
+
# Tolerate a concurrent creator winning the mkdir race, then validate
|
|
112
|
+
# the result before changing its permissions.
|
|
113
|
+
pass
|
|
114
|
+
if path_is_link_or_reparse(directory) or not directory.is_dir():
|
|
115
|
+
raise HookError(f"Private runtime path is not a safe directory: {directory}")
|
|
116
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
117
|
+
|
|
118
|
+
|
|
107
119
|
def ensure_private_directory(
|
|
108
120
|
path: pathlib.Path,
|
|
109
121
|
*,
|
|
@@ -119,8 +131,7 @@ def ensure_private_directory(
|
|
|
119
131
|
if path_is_link_or_reparse(current) or not current.is_dir():
|
|
120
132
|
raise HookError(f"Private runtime directory has an unsafe parent: {path}")
|
|
121
133
|
for directory in reversed(missing):
|
|
122
|
-
directory
|
|
123
|
-
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
134
|
+
_ensure_private_directory_component(directory)
|
|
124
135
|
if path_is_link_or_reparse(target) or not target.is_dir():
|
|
125
136
|
raise HookError(f"Private runtime directory is unsafe: {path}")
|
|
126
137
|
os.chmod(target, PRIVATE_DIRECTORY_MODE)
|
|
@@ -141,11 +152,12 @@ def ensure_private_directory(
|
|
|
141
152
|
raise HookError(
|
|
142
153
|
f"Private runtime directory traverses a symlink or reparse point: {directory}"
|
|
143
154
|
)
|
|
144
|
-
if
|
|
145
|
-
directory.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
if directory.exists():
|
|
156
|
+
if not directory.is_dir():
|
|
157
|
+
raise HookError(f"Private runtime path is not a directory: {directory}")
|
|
158
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
159
|
+
else:
|
|
160
|
+
_ensure_private_directory_component(directory)
|
|
149
161
|
return target
|
|
150
162
|
|
|
151
163
|
|
|
@@ -13,10 +13,12 @@ from __future__ import annotations # noqa: I001
|
|
|
13
13
|
import errno
|
|
14
14
|
import hashlib
|
|
15
15
|
import inspect
|
|
16
|
+
import itertools
|
|
16
17
|
import os
|
|
17
18
|
import pathlib
|
|
18
19
|
import re
|
|
19
20
|
import stat
|
|
21
|
+
import sys
|
|
20
22
|
import threading
|
|
21
23
|
from collections.abc import Callable, Mapping, Sequence
|
|
22
24
|
from types import ModuleType
|
|
@@ -35,6 +37,8 @@ _CALLABLE_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
|
35
37
|
_SOURCE_ENCODING = "utf-8"
|
|
36
38
|
PLUGIN_SOURCE_MAX_BYTES = 1 * 1024 * 1024
|
|
37
39
|
_SOURCE_READ_CHUNK_BYTES = 64 * 1024
|
|
40
|
+
_MODULE_NUMBER = itertools.count(1)
|
|
41
|
+
_MODULE_NUMBER_LOCK = threading.Lock()
|
|
38
42
|
_OS_OPEN = os.open
|
|
39
43
|
_DESCRIPTOR_RELATIVE_SUPPORTED = bool(
|
|
40
44
|
getattr(os, "O_DIRECTORY", 0)
|
|
@@ -221,7 +225,6 @@ class PluginLoader:
|
|
|
221
225
|
self._cache: dict[tuple[pathlib.Path, pathlib.Path], ModuleType] = {}
|
|
222
226
|
self._lock = threading.RLock()
|
|
223
227
|
self._source_locks: dict[tuple[pathlib.Path, pathlib.Path], threading.Lock] = {}
|
|
224
|
-
self._module_number = 0
|
|
225
228
|
|
|
226
229
|
def _source_lock(self, key: tuple[pathlib.Path, pathlib.Path]) -> threading.Lock:
|
|
227
230
|
with self._lock:
|
|
@@ -248,7 +251,12 @@ class PluginLoader:
|
|
|
248
251
|
# to the lexical cache key for a safe repository file.
|
|
249
252
|
try:
|
|
250
253
|
source_text = source.decode(_SOURCE_ENCODING)
|
|
251
|
-
code = compile(
|
|
254
|
+
code = compile(
|
|
255
|
+
source_text,
|
|
256
|
+
str(callback_path),
|
|
257
|
+
"exec",
|
|
258
|
+
dont_inherit=True,
|
|
259
|
+
)
|
|
252
260
|
except UnicodeDecodeError:
|
|
253
261
|
raise HookError(
|
|
254
262
|
f"Python plugin {relative_path}:{callable_name} source is not valid UTF-8"
|
|
@@ -258,16 +266,18 @@ class PluginLoader:
|
|
|
258
266
|
f"Python plugin {relative_path}:{callable_name} could not be compiled"
|
|
259
267
|
) from None
|
|
260
268
|
|
|
261
|
-
with
|
|
262
|
-
|
|
263
|
-
module_number = self._module_number
|
|
269
|
+
with _MODULE_NUMBER_LOCK:
|
|
270
|
+
module_number = next(_MODULE_NUMBER)
|
|
264
271
|
digest = hashlib.sha256(f"{root}\0{callback_path}".encode()).hexdigest()[:16]
|
|
265
272
|
module_name = f"ai_push_hooks_plugin_{digest}_{module_number}"
|
|
266
273
|
module = ModuleType(module_name)
|
|
267
274
|
module.__file__ = str(callback_path)
|
|
268
275
|
module.__package__ = ""
|
|
276
|
+
imported = False
|
|
277
|
+
sys.modules[module_name] = module
|
|
269
278
|
try:
|
|
270
279
|
exec(code, module.__dict__) # noqa: S102
|
|
280
|
+
imported = True
|
|
271
281
|
except KeyboardInterrupt:
|
|
272
282
|
raise
|
|
273
283
|
except SystemExit:
|
|
@@ -290,6 +300,9 @@ class PluginLoader:
|
|
|
290
300
|
raise HookError(
|
|
291
301
|
f"Python plugin {relative_path}:{callable_name} failed during import"
|
|
292
302
|
) from None
|
|
303
|
+
finally:
|
|
304
|
+
if not imported and sys.modules.get(module_name) is module:
|
|
305
|
+
del sys.modules[module_name]
|
|
293
306
|
return module
|
|
294
307
|
|
|
295
308
|
def load(self, repo_root: pathlib.Path, reference: str) -> Callable[..., Any]:
|
package/vendor/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The npm package includes the unmodified pure-Python wheel for [Tomli](https://pypi.org/project/tomli/2.4.0/). Python 3.10 uses it to parse TOML; Python 3.11+ uses the standard-library `tomllib` module.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The npm bootstrap adds the wheel directly to Python's import path, so no pip install, install script, or runtime download is needed. It does not inherit `PYTHONPATH`. The wheel includes Tomli's MIT license under `tomli-2.4.0.dist-info/licenses/LICENSE`.
|
|
6
6
|
|
|
7
7
|
To reproduce the bundled download from the repository root:
|
|
8
8
|
|