capt-hook 0.2.0__py3-none-any.whl
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.
- capt_hook-0.2.0.dist-info/METADATA +113 -0
- capt_hook-0.2.0.dist-info/RECORD +57 -0
- capt_hook-0.2.0.dist-info/WHEEL +4 -0
- capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
- capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
- captain_hook/__init__.py +246 -0
- captain_hook/__main__.py +6 -0
- captain_hook/app.py +278 -0
- captain_hook/classifiers/__init__.py +30 -0
- captain_hook/classifiers/conductor.py +35 -0
- captain_hook/classifiers/droid.py +20 -0
- captain_hook/classifiers/native.py +19 -0
- captain_hook/cli.py +341 -0
- captain_hook/command.py +356 -0
- captain_hook/conditions.py +136 -0
- captain_hook/context.py +161 -0
- captain_hook/dispatch.py +107 -0
- captain_hook/events.py +318 -0
- captain_hook/file.py +120 -0
- captain_hook/llm/__init__.py +9 -0
- captain_hook/llm/backends.py +152 -0
- captain_hook/loader.py +62 -0
- captain_hook/log.py +60 -0
- captain_hook/primitives/__init__.py +51 -0
- captain_hook/primitives/audit.py +71 -0
- captain_hook/primitives/commands.py +61 -0
- captain_hook/primitives/lint.py +216 -0
- captain_hook/primitives/llm.py +376 -0
- captain_hook/primitives/nudge.py +95 -0
- captain_hook/prompt.py +103 -0
- captain_hook/py.typed +1 -0
- captain_hook/session.py +158 -0
- captain_hook/settings.py +120 -0
- captain_hook/signals/__init__.py +86 -0
- captain_hook/signals/nlp.py +105 -0
- captain_hook/state.py +221 -0
- captain_hook/styleguide/__init__.py +183 -0
- captain_hook/styleguide/query.py +238 -0
- captain_hook/styleguide/scope.py +46 -0
- captain_hook/styleguide/types.py +70 -0
- captain_hook/tasks.py +112 -0
- captain_hook/templates/example_hook.py.tmpl +85 -0
- captain_hook/testing/__init__.py +10 -0
- captain_hook/testing/helpers.py +392 -0
- captain_hook/testing/session_cache.py +50 -0
- captain_hook/testing/types.py +88 -0
- captain_hook/tests/__init__.py +27 -0
- captain_hook/tests/helpers.py +361 -0
- captain_hook/tools.py +59 -0
- captain_hook/transcript/__init__.py +572 -0
- captain_hook/transcript/inputs.py +226 -0
- captain_hook/transcript/models.py +186 -0
- captain_hook/types.py +381 -0
- captain_hook/util/__init__.py +0 -0
- captain_hook/util/model_cache.py +87 -0
- captain_hook/utils.py +27 -0
- captain_hook/workflow.py +119 -0
captain_hook/log.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Captain-hook logging: one per-session loguru file sink plus stderr warnings.
|
|
2
|
+
|
|
3
|
+
Modules log through loguru directly (``from loguru import logger``), attaching
|
|
4
|
+
structured context with ``logger.bind(...)``. ``setup_logging()`` installs the
|
|
5
|
+
sinks and a patcher that truncates long bound values so call sites never have to
|
|
6
|
+
slice them by hand.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from captain_hook.session import session_hash
|
|
17
|
+
from captain_hook.settings import resolve_log_dir
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
from loguru import Record
|
|
23
|
+
|
|
24
|
+
MAX_BOUND_VALUE = 200
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def truncate_bound_values(record: Record) -> None:
|
|
28
|
+
"""Patcher: cap long string values bound via ``logger.bind(...)``."""
|
|
29
|
+
for key, value in record["extra"].items():
|
|
30
|
+
if isinstance(value, str) and len(value) > MAX_BOUND_VALUE:
|
|
31
|
+
record["extra"][key] = value[:MAX_BOUND_VALUE] + "…"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_format(template: str) -> Callable[[Record], str]:
|
|
35
|
+
"""Build a loguru format function that appends bound context and the traceback."""
|
|
36
|
+
|
|
37
|
+
def formatter(record: Record) -> str:
|
|
38
|
+
return (template + " | {extra}" if record["extra"] else template) + "\n{exception}"
|
|
39
|
+
|
|
40
|
+
return formatter
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setup_logging(transcript_path: str | None) -> None:
|
|
44
|
+
"""Route captain-hook logs to a per-session loguru file sink plus stderr warnings."""
|
|
45
|
+
session_id = session_hash(transcript_path) if transcript_path else "unknown"
|
|
46
|
+
log_dir = resolve_log_dir()
|
|
47
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
logger.configure(
|
|
50
|
+
patcher=truncate_bound_values,
|
|
51
|
+
handlers=[
|
|
52
|
+
{
|
|
53
|
+
"sink": str(log_dir / f"{session_id}.log"),
|
|
54
|
+
"level": "DEBUG",
|
|
55
|
+
"format": make_format("{time:YYYY-MM-DD HH:mm:ss,SSS} {level} {name}: {message}"),
|
|
56
|
+
"encoding": "utf-8",
|
|
57
|
+
},
|
|
58
|
+
{"sink": sys.stderr, "level": "WARNING", "format": make_format("{level}: {message}")},
|
|
59
|
+
],
|
|
60
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from captain_hook.primitives.audit import audit as audit
|
|
4
|
+
from captain_hook.primitives.audit import session_id_for as session_id_for
|
|
5
|
+
from captain_hook.primitives.commands import block_command as block_command
|
|
6
|
+
from captain_hook.primitives.commands import warn_command as warn_command
|
|
7
|
+
from captain_hook.primitives.lint import diff_lint as diff_lint
|
|
8
|
+
from captain_hook.primitives.lint import lint as lint
|
|
9
|
+
from captain_hook.primitives.llm import (
|
|
10
|
+
GateVerdict as GateVerdict,
|
|
11
|
+
)
|
|
12
|
+
from captain_hook.primitives.llm import (
|
|
13
|
+
NudgeVerdict as NudgeVerdict,
|
|
14
|
+
)
|
|
15
|
+
from captain_hook.primitives.llm import (
|
|
16
|
+
PromptCheckVerdict as PromptCheckVerdict,
|
|
17
|
+
)
|
|
18
|
+
from captain_hook.primitives.llm import (
|
|
19
|
+
llm_evaluate as llm_evaluate,
|
|
20
|
+
)
|
|
21
|
+
from captain_hook.primitives.llm import (
|
|
22
|
+
llm_gate as llm_gate,
|
|
23
|
+
)
|
|
24
|
+
from captain_hook.primitives.llm import (
|
|
25
|
+
llm_nudge as llm_nudge,
|
|
26
|
+
)
|
|
27
|
+
from captain_hook.primitives.llm import (
|
|
28
|
+
prompt_check as prompt_check,
|
|
29
|
+
)
|
|
30
|
+
from captain_hook.primitives.nudge import gate as gate
|
|
31
|
+
from captain_hook.primitives.nudge import nudge as nudge
|
|
32
|
+
from captain_hook.styleguide import styleguide as styleguide
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"GateVerdict",
|
|
36
|
+
"NudgeVerdict",
|
|
37
|
+
"PromptCheckVerdict",
|
|
38
|
+
"audit",
|
|
39
|
+
"block_command",
|
|
40
|
+
"diff_lint",
|
|
41
|
+
"gate",
|
|
42
|
+
"lint",
|
|
43
|
+
"llm_evaluate",
|
|
44
|
+
"llm_gate",
|
|
45
|
+
"llm_nudge",
|
|
46
|
+
"nudge",
|
|
47
|
+
"prompt_check",
|
|
48
|
+
"session_id_for",
|
|
49
|
+
"styleguide",
|
|
50
|
+
"warn_command",
|
|
51
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from captain_hook.app import on
|
|
12
|
+
from captain_hook.types import Event, HookResult, TCondition
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from captain_hook.events import BaseHookEvent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def session_id_for(evt: BaseHookEvent) -> str | None:
|
|
19
|
+
"""Return a 12-char sha256 prefix of the transcript path, or ``None`` if unavailable."""
|
|
20
|
+
return sha256(str(p).encode()).hexdigest()[:12] if (p := evt.ctx.t.path) else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_log_dir() -> Path:
|
|
24
|
+
return Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) / ".context" / "hook-logs"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_fields(evt: BaseHookEvent) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
30
|
+
"event": evt.event_name.name,
|
|
31
|
+
"tool": evt.tool_name,
|
|
32
|
+
"file": str(evt.file.path) if evt.file else None,
|
|
33
|
+
"session_id": session_id_for(evt),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def audit(
|
|
38
|
+
events: Event = Event.PreToolUse | Event.PostToolUse | Event.Stop,
|
|
39
|
+
*,
|
|
40
|
+
log_dir: Path | str | None = None,
|
|
41
|
+
filename: Callable[[datetime], str] = lambda d: f"{d:%Y-%m-%d}.jsonl",
|
|
42
|
+
fields: Callable[[BaseHookEvent], dict[str, Any]] = default_fields,
|
|
43
|
+
only_if: Sequence[TCondition] = (),
|
|
44
|
+
skip_if: Sequence[TCondition] = (),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Register a hook that appends one JSONL record per matching event.
|
|
47
|
+
|
|
48
|
+
Each matching event writes a single line to ``<log_dir>/<filename(now)>``.
|
|
49
|
+
Default fields are ``ts``, ``event``, ``tool``, ``file``, and ``session_id``
|
|
50
|
+
(a 12-char sha256 prefix of the transcript path).
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> from captain_hook import audit, Event
|
|
54
|
+
>>> audit(Event.PreToolUse | Event.PostToolUse | Event.Stop)
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
events: Event mask to audit. Defaults to PreToolUse | PostToolUse | Stop.
|
|
58
|
+
log_dir: Output directory. Defaults to ``$CLAUDE_PROJECT_DIR/.context/hook-logs``.
|
|
59
|
+
filename: ``(datetime) -> str`` mapping a timestamp to a filename.
|
|
60
|
+
fields: ``(evt) -> dict`` for the per-record payload.
|
|
61
|
+
only_if: Conditions that must match for the event to be recorded.
|
|
62
|
+
skip_if: Conditions that, if matched, suppress recording.
|
|
63
|
+
"""
|
|
64
|
+
resolved_dir = Path(log_dir) if log_dir else default_log_dir()
|
|
65
|
+
|
|
66
|
+
@on(events, only_if=only_if, skip_if=skip_if)
|
|
67
|
+
def audit_event(evt: BaseHookEvent) -> HookResult | None:
|
|
68
|
+
resolved_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
with (resolved_dir / filename(datetime.now(UTC))).open("a") as f:
|
|
70
|
+
f.write(json.dumps(fields(evt)) + "\n")
|
|
71
|
+
return None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from captain_hook.app import hook as register_hook
|
|
6
|
+
from captain_hook.types import Command, Event, InlineTests, Tool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def block_command_pattern(tokens: list[str]) -> str:
|
|
10
|
+
"""Convert a token list into a whitespace-flexible regex pattern.
|
|
11
|
+
|
|
12
|
+
``"*"`` becomes ``\\S+`` (any non-whitespace word), ``"a|b"`` becomes an
|
|
13
|
+
alternation group, and all other tokens are escaped.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> block_command_pattern(["git", "stash", "*"])
|
|
17
|
+
'git\\\\s+stash\\\\s+\\\\S+'
|
|
18
|
+
"""
|
|
19
|
+
def convert(token: str) -> str:
|
|
20
|
+
match token:
|
|
21
|
+
case "*":
|
|
22
|
+
return r"\S+"
|
|
23
|
+
case t if "|" in t:
|
|
24
|
+
return f"(?:{'|'.join(re.escape(a) for a in t.split('|'))})"
|
|
25
|
+
case t:
|
|
26
|
+
return re.escape(t)
|
|
27
|
+
|
|
28
|
+
return r"\s+".join(convert(t) for t in tokens)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def block_command(
|
|
32
|
+
pattern: str | list[str],
|
|
33
|
+
*,
|
|
34
|
+
reason: str,
|
|
35
|
+
hint: str | None = None,
|
|
36
|
+
tests: InlineTests | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Register a declarative hook that blocks a Bash command matching a pattern.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
>>> block_command(["git", "stash"], reason="git stash is not allowed", hint="Use jj")
|
|
42
|
+
"""
|
|
43
|
+
msg = f"BLOCKED: {reason}.{f' {hint}.' if hint else ''}"
|
|
44
|
+
cmd = Command(block_command_pattern(pattern) if isinstance(pattern, list) else pattern)
|
|
45
|
+
register_hook(Event.PreToolUse, only_if=[Tool("Bash"), cmd], message=msg, block=True, tests=tests)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warn_command(
|
|
49
|
+
pattern: str | list[str],
|
|
50
|
+
*,
|
|
51
|
+
message: str,
|
|
52
|
+
tests: InlineTests | None = None,
|
|
53
|
+
events: Event = Event.PostToolUse,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Register a declarative hook that warns on a Bash command matching a pattern.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> warn_command(["python", "-c", "*"], message="Prefer uv run mtest")
|
|
59
|
+
"""
|
|
60
|
+
cmd = Command(block_command_pattern(pattern) if isinstance(pattern, list) else pattern)
|
|
61
|
+
register_hook(events, only_if=[Tool("Bash"), cmd], message=message, tests=tests)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import textwrap
|
|
5
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, get_type_hints, overload
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from captain_hook.app import on
|
|
11
|
+
from captain_hook.state import hook_name
|
|
12
|
+
from captain_hook.types import (
|
|
13
|
+
Action,
|
|
14
|
+
Event,
|
|
15
|
+
FilePath,
|
|
16
|
+
HookResult,
|
|
17
|
+
InlineTests,
|
|
18
|
+
TCondition,
|
|
19
|
+
TestFile,
|
|
20
|
+
Tool,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from captain_hook.events import BaseHookEvent
|
|
25
|
+
|
|
26
|
+
StringCheck = Callable[[str], list[str]]
|
|
27
|
+
AstCheck = Callable[[ast.AST], Iterator[str]]
|
|
28
|
+
DiffCheck = Callable[[ast.AST, ast.AST], list[str]]
|
|
29
|
+
|
|
30
|
+
DEFAULT_ONLY_IF: tuple[TCondition, ...] = (Tool("Edit|Write"), FilePath("*.py", project_only=False))
|
|
31
|
+
DEFAULT_SKIP_IF: tuple[TCondition, ...] = (TestFile(),)
|
|
32
|
+
DIFF_DEFAULT_ONLY_IF: tuple[TCondition, ...] = (Tool("Edit"), FilePath("*.py", project_only=False))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_ast_mode(check: StringCheck | AstCheck) -> bool:
|
|
36
|
+
hints = get_type_hints(check)
|
|
37
|
+
first_param = next(iter(hints.values()), None)
|
|
38
|
+
return first_param is ast.AST or (isinstance(first_param, type) and issubclass(first_param, ast.AST))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_source(evt: BaseHookEvent) -> str | None:
|
|
42
|
+
if evt.file and evt.file.path.exists():
|
|
43
|
+
try:
|
|
44
|
+
return evt.file.read_text()
|
|
45
|
+
except (OSError, UnicodeDecodeError):
|
|
46
|
+
pass
|
|
47
|
+
return evt.content
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@overload
|
|
51
|
+
def lint(
|
|
52
|
+
check: Callable[[str], list[str]],
|
|
53
|
+
*,
|
|
54
|
+
message: str,
|
|
55
|
+
trigger: str | None = ...,
|
|
56
|
+
sep: str = ...,
|
|
57
|
+
block: bool = ...,
|
|
58
|
+
events: Event | None = ...,
|
|
59
|
+
tests: InlineTests | None = ...,
|
|
60
|
+
max_shown: int = ...,
|
|
61
|
+
) -> None: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@overload
|
|
65
|
+
def lint(
|
|
66
|
+
check: Callable[[ast.AST], Iterator[str]],
|
|
67
|
+
*,
|
|
68
|
+
message: str,
|
|
69
|
+
trigger: str | None = ...,
|
|
70
|
+
sep: str = ...,
|
|
71
|
+
block: bool = ...,
|
|
72
|
+
events: Event | None = ...,
|
|
73
|
+
tests: InlineTests | None = ...,
|
|
74
|
+
max_shown: int = ...,
|
|
75
|
+
) -> None: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def lint(
|
|
79
|
+
check: Callable[[str], list[str]] | Callable[[ast.AST], Iterator[str]],
|
|
80
|
+
*,
|
|
81
|
+
message: str,
|
|
82
|
+
trigger: str | None = None,
|
|
83
|
+
sep: str = ", ",
|
|
84
|
+
block: bool = False,
|
|
85
|
+
events: Event | None = None,
|
|
86
|
+
tests: InlineTests | None = None,
|
|
87
|
+
max_shown: int = 5,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Register a lint check that runs on Python file edits/writes.
|
|
90
|
+
|
|
91
|
+
Supports two modes based on the ``check`` function's type hint:
|
|
92
|
+
- **String mode**: receives the file content as ``str``, returns violation strings.
|
|
93
|
+
- **AST mode**: receives each ``ast.AST`` node, yields violation strings.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
>>> def find_prints(content: str) -> list[str]:
|
|
97
|
+
... return [line for line in content.splitlines() if "print(" in line]
|
|
98
|
+
>>> lint(find_prints, message="Remove print statements: {violations}")
|
|
99
|
+
"""
|
|
100
|
+
is_ast = detect_ast_mode(check)
|
|
101
|
+
|
|
102
|
+
def handler(evt: BaseHookEvent) -> HookResult | None:
|
|
103
|
+
try:
|
|
104
|
+
if is_ast:
|
|
105
|
+
return run_ast_check(check, evt, message, trigger, sep, block, max_shown) # type: ignore[arg-type]
|
|
106
|
+
return run_string_check(check, evt, message, sep, block, max_shown) # type: ignore[arg-type]
|
|
107
|
+
except Exception:
|
|
108
|
+
logger.bind(check=check.__name__).opt(exception=True).warning("lint check failed")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
handler.__name__ = handler.__qualname__ = hook_name("lint", None, message)
|
|
112
|
+
|
|
113
|
+
on(
|
|
114
|
+
events or Event.PostToolUse,
|
|
115
|
+
only_if=DEFAULT_ONLY_IF,
|
|
116
|
+
skip_if=DEFAULT_SKIP_IF,
|
|
117
|
+
tests=tests,
|
|
118
|
+
)(handler)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def diff_lint(
|
|
122
|
+
check: DiffCheck,
|
|
123
|
+
*,
|
|
124
|
+
message: str,
|
|
125
|
+
sep: str = ", ",
|
|
126
|
+
block: bool = False,
|
|
127
|
+
events: Event | None = None,
|
|
128
|
+
tests: InlineTests | None = None,
|
|
129
|
+
max_shown: int = 5,
|
|
130
|
+
only_if: Sequence[TCondition] = DIFF_DEFAULT_ONLY_IF,
|
|
131
|
+
skip_if: Sequence[TCondition] = DEFAULT_SKIP_IF,
|
|
132
|
+
) -> None:
|
|
133
|
+
def handler(evt: BaseHookEvent) -> HookResult | None:
|
|
134
|
+
try:
|
|
135
|
+
return run_diff_check(check, evt, message, sep, block, max_shown)
|
|
136
|
+
except Exception:
|
|
137
|
+
logger.bind(check=check.__name__).opt(exception=True).warning("diff lint check failed")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
handler.__name__ = handler.__qualname__ = hook_name("diff_lint", None, message)
|
|
141
|
+
|
|
142
|
+
on(
|
|
143
|
+
events or Event.PostToolUse,
|
|
144
|
+
only_if=only_if,
|
|
145
|
+
skip_if=skip_if,
|
|
146
|
+
tests=tests,
|
|
147
|
+
)(handler)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_string_check(
|
|
151
|
+
check: StringCheck,
|
|
152
|
+
evt: BaseHookEvent,
|
|
153
|
+
message: str,
|
|
154
|
+
sep: str,
|
|
155
|
+
block: bool,
|
|
156
|
+
max_shown: int,
|
|
157
|
+
) -> HookResult | None:
|
|
158
|
+
if (content := evt.content) is None:
|
|
159
|
+
return None
|
|
160
|
+
return format_result(check(content), message, sep, block, max_shown)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_diff_check(
|
|
164
|
+
check: DiffCheck,
|
|
165
|
+
evt: BaseHookEvent,
|
|
166
|
+
message: str,
|
|
167
|
+
sep: str,
|
|
168
|
+
block: bool,
|
|
169
|
+
max_shown: int,
|
|
170
|
+
) -> HookResult | None:
|
|
171
|
+
if (old := evt.old) is None or (new := evt.content) is None:
|
|
172
|
+
return None
|
|
173
|
+
try:
|
|
174
|
+
old_tree = ast.parse(textwrap.dedent(old))
|
|
175
|
+
new_tree = ast.parse(textwrap.dedent(new))
|
|
176
|
+
except SyntaxError:
|
|
177
|
+
return None
|
|
178
|
+
return format_result(check(old_tree, new_tree), message, sep, block, max_shown)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_ast_check(
|
|
182
|
+
check: AstCheck,
|
|
183
|
+
evt: BaseHookEvent,
|
|
184
|
+
message: str,
|
|
185
|
+
trigger: str | None,
|
|
186
|
+
sep: str,
|
|
187
|
+
block: bool,
|
|
188
|
+
max_shown: int,
|
|
189
|
+
) -> HookResult | None:
|
|
190
|
+
source = read_source(evt)
|
|
191
|
+
if source is None:
|
|
192
|
+
return None
|
|
193
|
+
if trigger and trigger not in source:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
tree = ast.parse(source)
|
|
197
|
+
except SyntaxError:
|
|
198
|
+
return None
|
|
199
|
+
violations = [v for node in ast.walk(tree) for v in check(node)]
|
|
200
|
+
return format_result(violations, message, sep, block, max_shown)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def format_result(
|
|
204
|
+
violations: list[str],
|
|
205
|
+
message: str,
|
|
206
|
+
sep: str,
|
|
207
|
+
block: bool,
|
|
208
|
+
max_shown: int,
|
|
209
|
+
) -> HookResult | None:
|
|
210
|
+
if not violations:
|
|
211
|
+
return None
|
|
212
|
+
formatted = sep.join(violations[:max_shown])
|
|
213
|
+
return HookResult(
|
|
214
|
+
action=Action.block if block else Action.warn,
|
|
215
|
+
message=message.format(violations=formatted),
|
|
216
|
+
)
|