docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
+7 -4
View File
@@ -277,7 +277,8 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec<HookSpec>, Vec<H
// Step 2: extract only the "hooks" key. If absent, the file has no hooks.
let hooks_value = match top_level.get("hooks") {
Some(v) => v.clone(),
None => return (specs, errors), // No hooks key — not an error, just no hooks.
// No hooks key — not an error, just no hooks.
None => return (specs, errors),
};
let hooks_map: HooksMap = match HooksMap::from_value(hooks_value) {
@@ -517,7 +518,8 @@ mod tests {
assert_eq!(s.event, HookEventName::PreToolUse);
assert!(s.matcher.is_some());
assert!(s.enabled);
assert_eq!(s.timeout_ms, 2000); // 2 seconds → 2000 ms
// 2 seconds → 2000 ms
assert_eq!(s.timeout_ms, 2000);
assert_eq!(s.command, Some(PathBuf::from("bin/check.sh")));
}
@@ -554,7 +556,8 @@ mod tests {
}"#;
let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json"));
assert!(errors.is_empty());
assert!(specs[0].matcher.is_none()); // empty string → None → match all
// empty string → None → match all
assert!(specs[0].matcher.is_none());
}
#[test]
@@ -1072,7 +1075,7 @@ mod tests {
);
}
/// `matcher` is intentionally NOT env-expanded. A
/// `matcher` is deliberately NOT env-expanded. A
/// matcher with `$VAR` must store the literal `$VAR` (anchored as
/// part of the regex by `HookMatcher::new`). A future contributor
/// adding "completeness" here would break regex semantics.
+9 -5
View File
@@ -248,7 +248,8 @@ fn load_hooks_from_settings_file(path: &Path) -> (Vec<HookSpec>, Vec<HookError>)
Ok(c) => c,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return (Vec::new(), Vec::new()); // Missing file is fine.
// Missing file is fine.
return (Vec::new(), Vec::new());
}
return (
Vec::new(),
@@ -406,7 +407,8 @@ mod tests {
#[test]
fn load_nonexistent_dir() {
let (registry, errors) = load_hooks(Some(Path::new("/nonexistent/path/hooks")), None);
assert!(errors.is_empty()); // NotFound is silent
// NotFound is silent
assert!(errors.is_empty());
assert!(registry.is_empty());
}
@@ -613,10 +615,11 @@ mod tests {
let toml = dir.path().join("hooks.toml");
std::fs::write(&toml, "").unwrap();
assert!(!is_valid_hook_file(&toml)); // TOML no longer accepted
// TOML no longer accepted
assert!(!is_valid_hook_file(&toml));
}
// ── Settings file discovery tests ────────────────────────────
// Settings file discovery tests
#[test]
fn load_from_settings_file() {
@@ -642,7 +645,8 @@ mod tests {
))],
&[],
);
assert!(errors.is_empty()); // Missing file is fine, not an error.
// Missing file is fine, not an error.
assert!(errors.is_empty());
assert!(registry.is_empty());
}
+9 -7
View File
@@ -412,7 +412,7 @@ mod tests {
registry
}
// ── extract_tool_name tests ──────────────────────────────────
// extract_tool_name tests
#[test]
fn extract_tool_name_from_pre_tool_use() {
@@ -453,7 +453,7 @@ mod tests {
);
}
// ── dispatch_pre_tool_use tests ──────────────────────────────
// dispatch_pre_tool_use tests
#[tokio::test]
async fn empty_registry_allows() {
@@ -501,7 +501,8 @@ mod tests {
let spec = make_command_spec(
"disabled-deny",
None,
false, // disabled!
// disabled!
false,
"echo '{\"decision\":\"deny\",\"reason\":\"should not run\"}'; exit 2",
);
let registry = registry_from_specs(vec![spec]);
@@ -726,7 +727,7 @@ mod tests {
assert_eq!(result.decision, HookDecision::Allow);
}
// ── fail-open regression tests ───────────────────────────────
// fail-open regression tests
#[tokio::test]
async fn fail_open_records_error_in_run_results() {
@@ -757,7 +758,7 @@ mod tests {
}
}
// ── dispatch_non_blocking tests ──────────────────────────────
// dispatch_non_blocking tests
#[tokio::test]
async fn non_blocking_empty_registry() {
@@ -827,7 +828,7 @@ mod tests {
assert!(matches!(results[1], HookRunResult::Success { .. }));
}
// ── hub_hook_kind tests ──────────────────────────────────────
// hub_hook_kind tests
#[test]
fn hub_hook_kind_returns_none_for_pre_tool_use() {
@@ -878,7 +879,8 @@ mod tests {
}
};
assert_eq!(
cases.len() + 1, // +1 for PreToolUse (blocking, tested separately)
// +1 for PreToolUse (blocking, tested separately)
cases.len() + 1,
total_variants(HookEventName::SessionStart),
"update hub_hook_kind test when new HookEventName variants are added"
);
+11 -11
View File
@@ -17,7 +17,7 @@
//!
//! * config-load-time expansion is idempotent (re-running it on an already
//! expanded string is a no-op),
//! * vars that are intentionally deferred to runtime (set later by the
//! * vars that are deliberately deferred to runtime (set later by the
//! shell, the dispatcher, or `extra_env`) survive the load-time pass and
//! are caught by the runtime pre-flight check in
//! [`crate::runner::command`] if they remain unset at execution, and
@@ -111,7 +111,7 @@ fn make_sentinel() -> String {
///
/// Unresolved references are preserved verbatim so this function is safe
/// to call repeatedly (idempotent on already-expanded strings) and so
/// references that are intentionally resolved at runtime (e.g. by the
/// references that are deliberately resolved at runtime (e.g. by the
/// dispatcher's always-set `KIGI_HOOK_*` vars) survive the load-time pass.
///
/// Parameter-expansion-modifier forms (`${VAR:-x}`, `${VAR%pat}`, etc.)
@@ -126,7 +126,7 @@ pub(crate) fn expand_env_vars_with_extra(input: &str, extra: &HashMap<String, St
// to appear in the input or in any extra-env value (vanishingly
// unlikely; would require an adversary to predict our PRNG output),
// panic in debug builds and fall through to legacy behaviour in
// release. Returning the input unchanged is safer than rewriting a
// release. Returning the input `unchanged` is safer than rewriting a
// legitimate substring to `${`.
debug_assert!(
!input.contains(&sentinel) && !extra.values().any(|v| v.contains(&sentinel)),
@@ -410,7 +410,7 @@ mod tests {
assert_eq!(expand_env_vars_with_extra("", &extra), "");
}
// ── Parameter-expansion-modifier preservation ───────────────
// Parameter-expansion-modifier preservation
/// `${VAR:-default}` must be preserved verbatim, even when `VAR` is
/// unset at expand time. Otherwise shellexpand resolves to the
@@ -525,7 +525,7 @@ mod tests {
assert_eq!(out, "/usr/local/${KIGI_HOOKS_DEFER:-/fallback}");
}
// ── Set-but-empty regression test ────────────────────────────
// Set-but-empty regression test
/// When the var is set in `extra` but to the empty string, the
/// no-modifier form `${VAR}` resolves to "" (matching shellexpand's
@@ -553,7 +553,7 @@ mod tests {
assert_eq!(out, input);
}
// ── Single-pass expansion (no recursion) ────────────────────
// Single-pass expansion (no recursion)
/// A value in `extra` that itself contains a `$VAR` reference must
/// NOT be re-expanded. Recursion would be a DoS vector and a
@@ -577,7 +577,7 @@ mod tests {
);
}
// ── mask_modifier_forms helper unit tests ────────────────────
// mask_modifier_forms helper unit tests
/// A fixed test-only sentinel used to make the masked-output
/// assertions deterministic. Production code uses [`make_sentinel`]
@@ -623,7 +623,7 @@ mod tests {
assert_eq!(masked, expected);
}
// ── Nested / interleaved edge cases ─────────────────────────
// Nested / interleaved edge cases
/// Two consecutive modifier forms with no
/// intervening text. Both must be masked independently.
@@ -667,7 +667,7 @@ mod tests {
assert_eq!(masked, "${A}${B:-");
}
// ── Sentinel collision regression ──────────────────────────
// Sentinel collision regression
/// The previous sentinel was `\x00\x00`. If a
/// future change reverted to that sentinel, an `extra_env` value
@@ -677,7 +677,7 @@ mod tests {
/// vanishingly unlikely to collide. This regression test
/// constructs an input containing the OLD `\x00\x00` sequence
/// AND a value containing the OLD sequence in `extra_env`, and
/// asserts both pass through unchanged.
/// asserts both pass through `unchanged`.
#[test]
fn mask_helper_preserves_pre_existing_old_nul_sentinel() {
// The OLD sentinel as a literal in the input.
@@ -752,7 +752,7 @@ mod tests {
assert_eq!(out, format!("X={exotic}"));
}
// ── iter_env_var_references unit tests ───────────────────────
// iter_env_var_references unit tests
/// Lock down the iterator output for a single braced plain form.
#[test]
+12 -11
View File
@@ -11,7 +11,7 @@ pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
// ── Session lifecycle ───────────────────────────────────────
// Session lifecycle
SessionStart,
SessionEnd,
/// Fires when an agent turn ends (completed, cancelled, or error).
@@ -19,7 +19,7 @@ pub enum HookEventName {
/// Fires when the turn ends due to an API error. Output and exit code are ignored.
StopFailure,
// ── Tool events ─────────────────────────────────────────────
// Tool events
PreToolUse,
PostToolUse,
/// Fires after a tool call fails (throws an error).
@@ -27,13 +27,13 @@ pub enum HookEventName {
/// Fires when a tool call is denied by the permission system.
PermissionDenied,
// ── User / notification events ──────────────────────────────
// User / notification events
/// Fires when the user submits a prompt.
UserPromptSubmit,
/// Fires when a notification is sent (e.g., permission prompt, idle).
Notification,
// ── Subagent events ─────────────────────────────────────────
// Subagent events
/// Fires when a subagent is spawned.
SubagentStart,
/// Fires when a subagent completes.
@@ -41,7 +41,7 @@ pub enum HookEventName {
/// Alias for SubagentStop (kept for backward compatibility).
SubagentEnd,
// ── Compaction events ───────────────────────────────────────
// Compaction events
/// Fires before context compaction.
PreCompact,
/// Fires after context compaction completes.
@@ -176,7 +176,7 @@ pub struct HookEventEnvelope {
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum HookPayload {
// ── Session lifecycle ───────────────────────────────────────
// Session lifecycle
SessionStart {
source: String,
#[serde(rename = "modelId", skip_serializing_if = "Option::is_none")]
@@ -198,7 +198,7 @@ pub enum HookPayload {
error: String,
},
// ── Tool events ─────────────────────────────────────────────
// Tool events
PreToolUse {
/// The tool the model invoked. For the meta-dispatch tools (`use_tool`
/// and the external MCP-call tool) this is the resolved underlying tool
@@ -265,7 +265,7 @@ pub enum HookPayload {
tool_input_truncated: bool,
},
// ── User / notification events ──────────────────────────────
// User / notification events
/// Fires when the user submits a prompt.
UserPromptSubmit {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -284,7 +284,7 @@ pub enum HookPayload {
level: Option<String>,
},
// ── Subagent events ─────────────────────────────────────────
// Subagent events
/// Fires when a subagent is spawned.
SubagentStart {
#[serde(rename = "subagentId")]
@@ -308,7 +308,7 @@ pub enum HookPayload {
duration_ms: Option<u64>,
},
// ── Compaction events ───────────────────────────────────────
// Compaction events
PreCompact {
/// "manual" or "auto".
source: String,
@@ -407,7 +407,8 @@ mod tests {
(HookEventName::PermissionDenied, "permission_denied"),
(HookEventName::SubagentStart, "subagent_start"),
(HookEventName::SubagentStop, "subagent_stop"),
(HookEventName::SubagentEnd, "subagent_stop"), // alias collapses
// alias collapses
(HookEventName::SubagentEnd, "subagent_stop"),
(HookEventName::PreCompact, "pre_compact"),
(HookEventName::PostCompact, "post_compact"),
];
+3 -7
View File
@@ -1,13 +1,9 @@
//! # kigi-hooks
//!
//! Runtime hook system for Kigi — file-based discovery, command execution,
//! and policy enforcement.
//!
//! ## Overview
//!
//! This crate provides a minimal hooks system for Kigi. Hooks are discovered
//! from dedicated directories (`~/.kigi/hooks/` and `<git-worktree-root>/.kigi/hooks/`),
//! defined in JSON files (compatible settings format), and executed as child processes.
//! Hooks are discovered from dedicated directories (`~/.kigi/hooks/` and
//! `<git-worktree-root>/.kigi/hooks/`), defined in JSON files (compatible
//! settings format), and executed as child processes.
//!
//! ## v0 scope
//!
+11 -7
View File
@@ -2,7 +2,7 @@ use kigi_tools::types::{claude_names_for, kigi_names_for};
use regex::Regex;
/// A compiled hook matcher for tool names. The pattern semantics are chosen so that
/// `matcher` entries in hooks migrated from other agent CLIs keep firing unchanged:
/// `matcher` entries in hooks migrated from other agent CLIs keep firing `unchanged`:
///
/// - an empty pattern or `"*"` matches every tool;
/// - a "simple" pattern (only `[A-Za-z0-9_|]`, i.e. a plain name or `|`-list) is an
@@ -117,7 +117,8 @@ mod tests {
// Contains regex metachars -> regex mode, unanchored.
let m = HookMatcher::new("run_.*").unwrap();
assert!(m.is_match("run_terminal_command"));
assert!(m.is_match("xrun_yyy")); // unanchored: substring match
// unanchored: substring match
assert!(m.is_match("xrun_yyy"));
assert!(!m.is_match("read_file"));
}
@@ -152,13 +153,14 @@ mod tests {
assert!(!m.is_match("run_terminal_command"));
}
// ── External tool-name aliases ────────────────────────────────
// External tool-name aliases
#[test]
fn claude_bash_matches_kigi_tool() {
let m = HookMatcher::new("Bash").unwrap();
assert!(m.is_match("Bash")); // external alias name
assert!(m.is_match("run_terminal_command")); // Kigi name
// external alias name
assert!(m.is_match("Bash"));
assert!(m.is_match("run_terminal_command"));
assert!(!m.is_match("read_file"));
// Bug-fix regression: exact, not prefix.
assert!(!m.is_match("run_terminal_command_v2"));
@@ -169,8 +171,10 @@ mod tests {
let m = HookMatcher::new("Edit|Write").unwrap();
assert!(m.is_match("Edit"));
assert!(m.is_match("Write"));
assert!(m.is_match("search_replace")); // Kigi equivalent
assert!(m.is_match("hashline_edit")); // second Kigi alias
// Kigi equivalent
assert!(m.is_match("search_replace"));
// second Kigi alias
assert!(m.is_match("hashline_edit"));
assert!(!m.is_match("read_file"));
// The old anchoring bug matched these; the exact-list mode must not.
assert!(!m.is_match("Editorial"));
+2 -8
View File
@@ -11,9 +11,7 @@ pub enum HookDecision {
/// HTTP-specific execution details for scrollback enrichment.
///
/// Populated only for `"http"` handler type hooks. Carries the target
/// URL, HTTP status, and a short preview of the response body so that
/// scrollback annotations can display them.
/// Populated only for `"http"` handler type hooks.
#[derive(Debug, Clone)]
pub struct HttpInfo {
/// The URL that was POSTed to.
@@ -39,8 +37,7 @@ pub struct HttpInfo {
///
/// [`url`]: HttpInfo::url
pub raw_url: Option<String>,
/// HTTP status code (e.g. 200, 500). `None` if the request never
/// completed (timeout, connection error).
/// `None` if the request never completed (timeout, connection error).
pub status: Option<u16>,
/// Short preview of the response body (truncated to ~200 chars).
/// `None` if no body was read (e.g. non-blocking hooks, timeouts).
@@ -50,11 +47,9 @@ pub struct HttpInfo {
/// The outcome of a single hook execution.
#[derive(Debug)]
pub enum HookRunResult {
/// Hook executed successfully.
Success {
hook_name: String,
elapsed: Duration,
/// HTTP details, populated only for `"http"` handler type hooks.
http_info: Option<HttpInfo>,
},
/// Hook was skipped because it is disabled.
@@ -64,7 +59,6 @@ pub enum HookRunResult {
hook_name: String,
error: String,
elapsed: Duration,
/// HTTP details, populated only for `"http"` handler type hooks.
http_info: Option<HttpInfo>,
},
}
+13 -11
View File
@@ -146,7 +146,7 @@ pub async fn run_command_hook(
// cannot open /dev/tty and corrupt the TUI display. Delegates to
// `kigi_tools::util::detach_command`: Unix uses the same setsid /
// EPERM→setpgid pre_exec path as before; Windows sets CREATE_NO_WINDOW only
// (DETACHED_PROCESS is intentionally omitted — it breaks stdio inheritance).
// (DETACHED_PROCESS is deliberately omitted — it breaks stdio inheritance).
kigi_tools::util::detach_command(&mut cmd);
// Spawn the child process.
@@ -649,7 +649,8 @@ mod tests {
let large = vec![b'x'; MAX_OUTPUT_BYTES + 1000];
let result = truncate_output(&large);
assert!(result.ends_with(" [truncated]"));
assert!(result.len() > MAX_OUTPUT_BYTES); // marker appended
// marker appended
assert!(result.len() > MAX_OUTPUT_BYTES);
}
#[test]
@@ -833,11 +834,12 @@ mod tests {
#[test]
fn shell_command_detection() {
// Commands with shell metacharacters should be detected.
assert!("echo hello".contains(' ')); // space
assert!("a || b".contains('|')); // pipe/or
assert!("a && b".contains('&')); // and
assert!("a; b".contains(';')); // semicolon
assert!("a > out".contains('>')); // redirect
assert!("echo hello".contains(' '));
// pipe/or
assert!("a || b".contains('|'));
assert!("a && b".contains('&'));
assert!("a; b".contains(';'));
assert!("a > out".contains('>'));
// Env-var interpolation must also force the sh -c branch so that
// commands like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` get expanded
// by the shell rather than treated as a literal executable path.
@@ -860,7 +862,7 @@ mod tests {
/// Regression: a hook command that uses `${VAR}` interpolation
/// without any other shell metacharacters must still be invoked via
/// `sh -c` so that the env var supplied via `extra_env` is expanded.
/// Previously the runner treated `${...}` as part of a literal path
/// earlier the runner treated `${...}` as part of a literal path
/// and `command_path.exists()` failed; the hook silently never ran.
/// Now the env-var pre-spawn check refuses with a clear reason when
/// the var is unset (and the dispatcher fail-opens, so the tool call
@@ -1104,7 +1106,7 @@ mod tests {
#[tokio::test]
async fn test_undefined_env_var_refuses_to_spawn() {
let mut extra_env = std::collections::HashMap::new();
// Intentionally do NOT set NEVER_SET_GB1183 anywhere.
// Deliberately do NOT set NEVER_SET_GB1183 anywhere.
extra_env.insert("UNRELATED_GB1183".to_string(), "/tmp".to_string());
let spec = HookSpec {
@@ -1150,7 +1152,7 @@ mod tests {
/// Regression: a hook command starting with `~` must be
/// routed through `sh -c` so the shell expands `~` to `$HOME`.
/// Previously `~/.claude/hook.sh` was treated as a relative path and
/// earlier `~/.claude/hook.sh` was treated as a relative path and
/// joined to `source_dir`, producing a broken path.
///
/// The test injects `HOME` via `extra_env` so it works in sandboxed
@@ -1245,7 +1247,7 @@ mod tests {
configured_matcher: None,
matcher: None,
enabled: true,
// `MISSING_GB1183_DEFAULT` is intentionally unset; the `:-`
// `MISSING_GB1183_DEFAULT` is deliberately unset; the `:-`
// modifier supplies a fallback that points at the real script.
command: Some(std::path::PathBuf::from(format!(
"${{MISSING_GB1183_DEFAULT:-{}}}",
+27 -16
View File
@@ -35,44 +35,55 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
IpAddr::V4(v4) => {
let octets = v4.octets();
if octets[0] == 127 {
return false; // loopback — allowed for local dev
// loopback — allowed for local dev
return false;
}
if octets[0] == 10 {
return true; // RFC 1918: 10.0.0.0/8
// RFC 1918: 10.0.0.0/8
return true;
}
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
return true; // RFC 1918: 172.16.0.0/12
// RFC 1918: 172.16.0.0/12
return true;
}
if octets[0] == 192 && octets[1] == 168 {
return true; // RFC 1918: 192.168.0.0/16
// RFC 1918: 192.168.0.0/16
return true;
}
if octets[0] == 169 && octets[1] == 254 {
return true; // RFC 3927: 169.254.0.0/16 (link-local, cloud metadata)
// RFC 3927: 169.254.0.0/16 (link-local, cloud metadata)
return true;
}
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
return true; // RFC 6598: 100.64.0.0/10 (CGNAT)
// RFC 6598: 100.64.0.0/10 (CGNAT)
return true;
}
if v4.is_unspecified() {
return true; // 0.0.0.0
// 0.0.0.0
return true;
}
false
}
IpAddr::V6(v6) => {
if v6.is_loopback() {
return false; // ::1 — allowed for local dev
// ::1 — allowed for local dev
return false;
}
if v6.is_unspecified() {
return true; // ::
// ::
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return is_blocked_ip(&IpAddr::V4(v4));
}
let segments = v6.segments();
if segments[0] & 0xffc0 == 0xfe80 {
return true; // fe80::/10 — link-local
// fe80::/10 — link-local
return true;
}
if segments[0] & 0xfe00 == 0xfc00 {
return true; // fc00::/7 — unique local (ULA)
// fc00::/7 — unique local (ULA)
return true;
}
false
}
@@ -398,7 +409,7 @@ mod tests {
use super::*;
use reqwest::StatusCode;
// ── parse_http_blocking_result tests ──────────────────────────
// parse_http_blocking_result tests
#[test]
fn http_allow_json() {
@@ -560,7 +571,7 @@ mod tests {
}
}
// ── SSRF protection: is_blocked_ip tests ──────────────
// SSRF protection: is_blocked_ip tests
#[test]
fn ssrf_blocks_rfc1918_10x() {
@@ -632,7 +643,7 @@ mod tests {
));
}
// ── SSRF protection: validate_hook_url tests ──────────
// SSRF protection: validate_hook_url tests
#[tokio::test]
async fn ssrf_rejects_http_scheme() {
@@ -675,7 +686,7 @@ mod tests {
assert!(result.unwrap_err().contains("invalid URL"));
}
// ── URL env-var expansion (extra_env precedence) ───────────
// URL env-var expansion (extra_env precedence)
use crate::config::HookSpec;
use crate::event::{HookEventEnvelope, HookEventName, HookPayload};
@@ -883,7 +894,7 @@ mod tests {
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await;
// Either `Failed` (timeout / connection error) is fine; both
// exercise paths that previously embedded the raw URL via
// exercise paths that earlier embedded the raw URL via
// `format!("...{e}")`. Pure timeouts use a different
// formatting branch (no URL involved), so prefer the
// connection-error case but tolerate either.
+3 -12
View File
@@ -7,32 +7,23 @@ use crate::config::HookSpec;
use crate::event::HookEventEnvelope;
use crate::result::{HookDecision, HttpInfo};
/// Context passed to any hook runner for environment setup.
pub struct RunContext<'a> {
pub session_id: &'a str,
pub workspace_root: &'a str,
}
/// Result of running a single hook (any handler type).
#[derive(Debug)]
pub enum HookRunnerResult {
/// Hook ran and produced a decision (for blocking hooks).
Decision(HookDecision),
/// Hook ran successfully (for non-blocking hooks).
Success,
/// Hook failed — caller should fail-open.
/// Callers must fail open on this variant: a broken hook never blocks the
/// session.
Failed(String),
}
/// Bundle returned by each runner: the result, wall-clock duration, and
/// optional HTTP metadata for enriched scrollback logging.
/// Result, wall-clock duration, and HTTP metadata for scrollback enrichment.
pub type HookRunOutput = (HookRunnerResult, Duration, Option<HttpInfo>);
/// Run a hook using the appropriate handler for its type.
///
/// Dispatches to `command::run_command_hook()` or `http::run_http_hook()`
/// based on `spec.handler_type`. Returns the result, elapsed duration, and
/// optional HTTP metadata for scrollback enrichment.
pub async fn run_hook(
spec: &HookSpec,
envelope: &HookEventEnvelope,
+12 -26
View File
@@ -1,32 +1,18 @@
//! Test-only helpers shared across `kigi-hooks` unit + integration tests.
//!
//! This module is gated on `#[cfg(test)]` and is exported as `pub(crate)`
//! so any in-crate `#[cfg(test)] mod tests` can use it. Integration tests
//! under `tests/` cannot reach it; for those, copy or re-implement the
//! handful of functions here that they need (the only one currently used
//! by integration tests is unrelated).
//! Test-only helpers for `kigi-hooks`. Gated on `#[cfg(test)]`, so integration
//! tests under `tests/` cannot reach it — they must re-implement what they need.
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
/// Run `f` with the env var `name` set to `value` (or unset if `value`
/// is `None`), restoring the previous value on return.
///
/// Uses `catch_unwind` so a panic inside `f` does not leak the env var
/// into the rest of the test process.
/// The save -> set -> run -> restore lifecycle is panic-safe but not race-safe:
/// `cargo test` runs tests in parallel and env vars are process-global, so
/// callers must pick uniquely-named vars.
///
/// `cargo test` runs tests in parallel by default. Process env vars are
/// process-global, so callers should pick uniquely-named vars to avoid
/// inter-test races. The lifecycle here (save -> set -> run -> restore)
/// is panic-safe but not race-safe.
///
/// **FOLLOW-UP**: the helper does not
/// enforce the unique-name discipline -- a future contributor passing
/// a common name like `HOME` could trigger flaky tests. The standard
/// fix is to add `serial_test` as a dev-dep and decorate every
/// env-touching test with `#[serial(env_var)]` so the test runner
/// serialises them. For now the unique-name
/// convention plus `catch_unwind` restoration is sufficient for the
/// tests that ship today.
/// FIXME: nothing enforces the unique-name discipline; a caller passing `HOME`
/// would produce flaky tests. The fix is a `serial_test` dev-dep plus
/// `#[serial(env_var)]` on every env-touching test.
pub(crate) fn with_env_var<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
let previous = std::env::var_os(name);
// SAFETY: env-var writes are not thread-safe. Callers use uniquely
@@ -74,7 +60,7 @@ mod tests {
#[test]
fn restores_previous_unset_state_on_normal_return() {
let key = "KIGI_HOOKS_TEST_SUPPORT_UNSET_RESTORE";
// SAFETY: see module-level note.
// SAFETY: see the thread-safety note on `with_env_var`.
unsafe {
std::env::remove_var(key);
}
@@ -87,7 +73,7 @@ mod tests {
#[test]
fn restores_after_panic() {
let key = "KIGI_HOOKS_TEST_SUPPORT_PANIC_RESTORE";
// SAFETY: see module-level note.
// SAFETY: see the thread-safety note on `with_env_var`.
unsafe {
std::env::remove_var(key);
}
@@ -106,7 +92,7 @@ mod tests {
#[test]
fn allows_explicit_unset() {
let key = "KIGI_HOOKS_TEST_SUPPORT_EXPLICIT_UNSET";
// SAFETY: see module-level note.
// SAFETY: see the thread-safety note on `with_env_var`.
unsafe {
std::env::set_var(key, "before");
}
@@ -114,7 +100,7 @@ mod tests {
assert!(std::env::var(key).is_err());
});
assert_eq!(std::env::var(key).unwrap(), "before");
// SAFETY: see module-level note.
// SAFETY: see the thread-safety note on `with_env_var`.
unsafe {
std::env::remove_var(key);
}
+3 -2
View File
@@ -34,7 +34,7 @@ pub fn list_trusted_projects_with_file(trust_file: &Path) -> std::io::Result<Vec
.collect())
}
// ── Hook enable/disable ─────────────────────────────────────────────────
// Hook enable/disable
/// Check whether a hook is disabled by name.
///
@@ -65,7 +65,8 @@ pub fn disable_hook(hook_name: &str) -> Result<(), String> {
fn disable_hook_with_file(hook_name: &str, file: &Path) -> Result<(), String> {
if is_hook_disabled_with_file(hook_name, file) {
return Ok(()); // Already disabled.
// Already disabled.
return Ok(());
}
if let Some(parent) = file.parent() {
let _ = std::fs::create_dir_all(parent);
@@ -382,7 +382,8 @@ async fn hook_receives_env_vars() {
// Verify env vars were received.
let output = std::fs::read_to_string(&output_file).unwrap();
assert!(output.contains("EVENT=pre_tool_use"), "output: {output}");
assert!(output.contains("NAME="), "output: {output}"); // auto-generated name
// auto-generated name
assert!(output.contains("NAME="), "output: {output}");
assert!(output.contains("SESSION=sess-456"), "output: {output}");
}