From a16ad92907f774a9b933a147a0292d7f594cb9d4 Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Thu, 11 Jun 2026 18:07:24 +0300 Subject: [PATCH 1/7] feat: refactor bin/main.rs for testability, add integration tests --- core/src/bin/main.rs | 104 +++++++++++++++++++++++++++++++------------ core/src/cel_eval.rs | 2 +- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index c8e0aff..0616acd 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -1,4 +1,5 @@ use std::env; +use std::io::Read; use std::path::{Path, PathBuf}; use std::process; @@ -8,21 +9,10 @@ fn main() { let repo_root = find_repo_root_from(&env::current_dir().unwrap()) .unwrap_or_else(|| env::current_dir().unwrap()); - let ph_event = match polyhook::read() { - Ok(e) => e, + let response = match run_app(std::io::stdin(), &repo_root) { + Ok(r) => r, Err(e) => { - eprintln!("steplock: failed to read hook input: {e}"); - process::exit(2); - } - }; - - let event = polyhook_to_hook_event(ph_event); - - let response = match run(&event, &repo_root) { - Ok(HookResponse::Approve) => polyhook::HookResponse::approve(), - Ok(HookResponse::Block { message }) => polyhook::HookResponse::block(&message), - Err(e) => { - eprintln!("steplock: error: {e}"); + eprintln!("{e}"); process::exit(2); } }; @@ -33,6 +23,26 @@ fn main() { } } +/// Parse the hook event from `reader`, run the gate, and return the polyhook response. +/// Returns `Err(message)` when input is unreadable or the gate engine fails. +fn run_app(mut reader: impl Read, repo_root: &Path) -> Result { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|e| format!("steplock: failed to read hook input: {e}"))?; + + let ph_event = polyhook::parse::parse_event(&bytes) + .map_err(|e| format!("steplock: failed to read hook input: {e}"))?; + + let event = polyhook_to_hook_event(ph_event); + + match run(&event, repo_root) { + Ok(HookResponse::Approve) => Ok(polyhook::HookResponse::approve()), + Ok(HookResponse::Block { message }) => Ok(polyhook::HookResponse::block(&message)), + Err(e) => Err(format!("steplock: error: {e}")), + } +} + fn polyhook_to_hook_event(e: polyhook::HookEvent) -> HookEvent { HookEvent { event: e.event.to_string(), @@ -66,7 +76,7 @@ mod tests { use std::fs; use tempfile::TempDir; - fn make_claude_stdin(cmd: &str, session: &str) -> String { + fn claude_stdin(cmd: &str, session: &str) -> String { serde_json::json!({ "hook_event_name": "PreToolUse", "tool_name": "Bash", @@ -102,7 +112,7 @@ reset = "session" #[test] fn polyhook_event_maps_correctly() { - let stdin = make_claude_stdin("git push origin main", "s1"); + let stdin = claude_stdin("git push origin main", "s1"); let ph_event = polyhook::parse::parse_event(stdin.as_bytes()).unwrap(); let event = polyhook_to_hook_event(ph_event); assert_eq!(event.event, "tool:before"); @@ -116,25 +126,56 @@ reset = "session" } #[test] - fn approves_non_matching_command() { + fn run_app_approves_non_matching_command() { let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); - let stdin = make_claude_stdin("ls -la", "s1"); - let ph_event = polyhook::parse::parse_event(stdin.as_bytes()).unwrap(); - let event = polyhook_to_hook_event(ph_event); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); + let stdin = claude_stdin("ls -la", "s1"); + let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + assert!(matches!(resp, polyhook::HookResponse::ApproveResponse(_))); } #[test] - fn blocks_matching_command() { + fn run_app_blocks_matching_command() { let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); - let stdin = make_claude_stdin("git push origin main", "s1"); - let ph_event = polyhook::parse::parse_event(stdin.as_bytes()).unwrap(); - let event = polyhook_to_hook_event(ph_event); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Block { .. })); + let stdin = claude_stdin("git push origin main", "s1"); + let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); + } + + #[test] + fn run_app_error_on_invalid_input() { + let tmp = TempDir::new().unwrap(); + let err = run_app(b"not valid json".as_ref(), tmp.path()); + assert!(err.is_err()); + assert!(err + .unwrap_err() + .contains("steplock: failed to read hook input")); + } + + #[test] + fn run_app_error_on_invalid_cel_expression() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/bad-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "!!!invalid cel!!!" +reset = "session" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> check\n check --> [*]\n check: Check\n", + ) + .unwrap(); + let stdin = claude_stdin("anything", "s1"); + let err = run_app(stdin.as_bytes(), tmp.path()); + assert!(err.is_err()); + assert!(err.unwrap_err().contains("steplock: error:")); } #[test] @@ -154,4 +195,11 @@ reset = "session" let root = find_repo_root_from(&subdir).unwrap(); assert_eq!(root, tmp.path()); } + + #[test] + fn find_repo_root_returns_none_when_not_found() { + let tmp = TempDir::new().unwrap(); + let result = find_repo_root_from(tmp.path()); + assert!(result.is_none()); + } } diff --git a/core/src/cel_eval.rs b/core/src/cel_eval.rs index a2bd664..4903e21 100644 --- a/core/src/cel_eval.rs +++ b/core/src/cel_eval.rs @@ -245,7 +245,7 @@ mod tests { #[test] fn json_float_input_converts() { let mut input = HashMap::new(); - input.insert("val".to_owned(), json!(3.14f64)); + input.insert("val".to_owned(), json!(1.23f64)); let ev = HookEvent { event: "tool:before".to_owned(), tool: "bash".to_owned(), From 7918afb935f7c7d6c662f8dd1a98f4fc2558c8bd Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Thu, 11 Jun 2026 18:26:24 +0300 Subject: [PATCH 2/7] feat: add --version and init subcommands to binary --- core/src/bin/main.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index 0616acd..8c2e8f7 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -1,11 +1,43 @@ use std::env; +use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use std::process; use steplock_core::{run, HookEvent, HookResponse}; +const VERSION: &str = env!("CARGO_PKG_VERSION"); + fn main() { + let args: Vec = env::args().skip(1).collect(); + match args.as_slice() { + [flag] if flag == "--version" || flag == "-V" => { + println!("steplock {VERSION}"); + } + [cmd] if cmd == "init" => { + let cwd = env::current_dir().unwrap(); + if let Err(e) = cmd_init(&cwd) { + eprintln!("steplock init: {e}"); + process::exit(1); + } + } + [] => run_hook(), + _ => { + eprintln!("steplock: unknown arguments"); + eprintln!("Usage: steplock [--version | init]"); + process::exit(1); + } + } +} + +fn cmd_init(root: &Path) -> std::io::Result<()> { + let checklists = root.join(".steplock/checklists"); + fs::create_dir_all(&checklists)?; + println!("steplock: created {}", checklists.display()); + Ok(()) +} + +fn run_hook() { let repo_root = find_repo_root_from(&env::current_dir().unwrap()) .unwrap_or_else(|| env::current_dir().unwrap()); @@ -110,6 +142,26 @@ reset = "session" .unwrap(); } + #[test] + fn version_string_is_nonempty() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn cmd_init_creates_checklists_dir() { + let tmp = TempDir::new().unwrap(); + cmd_init(tmp.path()).unwrap(); + assert!(tmp.path().join(".steplock/checklists").is_dir()); + } + + #[test] + fn cmd_init_is_idempotent() { + let tmp = TempDir::new().unwrap(); + cmd_init(tmp.path()).unwrap(); + cmd_init(tmp.path()).unwrap(); + assert!(tmp.path().join(".steplock/checklists").is_dir()); + } + #[test] fn polyhook_event_maps_correctly() { let stdin = claude_stdin("git push origin main", "s1"); From 1d7aa191132848d20de8c2ecc4b32e4fb0afab0e Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Thu, 11 Jun 2026 18:30:05 +0300 Subject: [PATCH 3/7] docs: add CONTRIBUTING.md, fix Architecture.md title and config path --- .gitignore | 1 + Architecture.md | 4 +-- CONTRIBUTING.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/.gitignore b/.gitignore index 2551513..7a020e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.local.* target/ +*.profraw # steplock runtime state — commit checklists, ignore everything else .steplock/sessions/ diff --git a/Architecture.md b/Architecture.md index 8aafe96..35d8ec3 100644 --- a/Architecture.md +++ b/Architecture.md @@ -1,4 +1,4 @@ -# +# Architecture ## Multi-agent Safety @@ -187,7 +187,7 @@ Or embed as a library and call `steplock::run()` before your own logic. | ------ | ---------------------------------- | --------------------------------------- | | Scope | Normalize hook events across tools | Gate actions behind stateful checklists | | State | Stateless | Stateful (disk-backed) | -| Config | None | `steplock.toml` | +| Config | None | `.steplock/checklists/*/config.toml` | | Layer | Core SDK | Built on top of polyhook | | WASM | Yes — detection + serde | No — pure host logic | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3d53307 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing + +## Setup + +Requires [Rust](https://rustup.rs) (stable toolchain). + +```sh +git clone https://github.com/polyhook/steplock.git +cd steplock +cargo build +cargo test +``` + +## Project layout + +``` +steplock/ +├── core/ ← Rust crate (library + binary) +│ └── src/ +│ ├── bin/main.rs ← CLI entry point +│ ├── lib.rs ← public re-exports +│ ├── run.rs ← gate logic (main entry point: run()) +│ ├── cel_eval.rs ← CEL expression evaluation +│ ├── config.rs ← config.toml parsing +│ ├── flow.rs ← Mermaid stateDiagram-v2 parsing +│ ├── state.rs ← session state (state.json) +│ ├── scripts.rs ← ack.sh / preview.sh generation +│ └── audit.rs ← audit.log append +├── schemas/ ← JSON schemas for config.toml and state.json +├── examples/ ← working example configurations +├── scripts/ ← reference shell scripts (ack.sh, preview.sh) +├── Architecture.md ← design decisions and data flow +├── Installation.md ← end-user installation guide +└── Roadmap.md ← planned features +``` + +## Running tests + +```sh +cd core +cargo test # all tests +cargo test --all-targets # includes binary tests +``` + +## Code coverage + +```sh +cd core +cargo llvm-cov --summary-only +``` + +## Linting + +```sh +cd core +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +## Checklist gates (this repo uses steplock) + +Every `git commit` triggers a 4-step DDD naming checklist. Every `git push` triggers a 6-step quality checklist. The hook binary must be installed and registered: + +```sh +cargo install --path core +``` + +Then register in `.claude/settings.json` (already present in this repo). When the hook blocks a command, run the `ack.sh` printed in the message, then retry. + +## Pull requests + +Open a draft PR against `main`. CI runs on every PR — `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`, and release builds on ubuntu + macos. From e9b4c28e6c7e891308558e4a0eca1456c526bbab Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Thu, 11 Jun 2026 18:35:28 +0300 Subject: [PATCH 4/7] docs: clarify language support status in packages/README.md --- packages/README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/README.md b/packages/README.md index d0d0602..cd37408 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,12 +1,14 @@ ## Language Support -Ships as a standalone CLI binary (works with any language) and as a native library for each polyhook SDK language. +Ships as a standalone CLI binary (works with any language) and as a native library for polyhook SDK languages. -| Distribution | Usage | -| ------------ | ------------------------------------------ | -| CLI binary | Drop-in hook script, zero code required | -| Rust crate | `steplock` — embed in existing hook binary | -| TypeScript | `@steplock/sdk` — wrap your hook handler | -| Go | `github.com/polyhook/steplock` | -| Python | `steplock` | -| C# | `Steplock` | +| Distribution | Status | Usage | +| ------------ | --------- | ------------------------------------------ | +| CLI binary | Available | Drop-in hook script, zero code required | +| Rust crate | Available | `steplock-core` — embed in existing binary | +| TypeScript | Planned | `@steplock/sdk` | +| Go | Planned | `github.com/polyhook/steplock` | +| Python | Planned | `steplock` | +| C# | Planned | `Steplock` | + +See [Roadmap.md](../Roadmap.md) for timeline. From 12193dcd743a33b82f9054d9a119493573f0e406 Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Thu, 11 Jun 2026 18:38:57 +0300 Subject: [PATCH 5/7] fix: correct cargo install command to steplock-core --- Installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Installation.md b/Installation.md index 6d6b648..7b1bd15 100644 --- a/Installation.md +++ b/Installation.md @@ -54,7 +54,7 @@ mv target/release/steplock /usr/local/bin/ ```sh # Cargo -cargo install steplock +cargo install steplock-core ``` --- From 2d0854c1163a4205dbbeb48fabdc82d4a5db3fa3 Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Thu, 11 Jun 2026 18:50:32 +0300 Subject: [PATCH 6/7] docs: add rustdoc to public API, fix [*] escaping, fix schema transitions minItems --- core/src/config.rs | 10 ++++++++++ core/src/flow.rs | 14 ++++++++------ core/src/state.rs | 2 ++ schemas/session-state.schema.json | 5 ++--- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/core/src/config.rs b/core/src/config.rs index a4f7e14..5469c4c 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,24 +1,34 @@ use serde::{Deserialize, Serialize}; +/// When to reset checklist progress for a scope. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum Reset { + /// Reset when the agent session ends (default). Progress persists across retries within the same session. #[default] Session, + /// Reset on every invocation — no state persisted. Always, } +/// Parsed representation of a checklist's `config.toml`. #[derive(Debug, Clone, Deserialize)] pub struct ChecklistConfig { + /// Polyhook event type that triggers this checklist (e.g. `"tool:before"`). pub on_event: String, + /// Normalized polyhook tool name to match (e.g. `"bash"`, `"write_file"`). pub on_tool: String, + /// Optional CEL expression evaluated against the hook event. Omit to match all invocations. pub match_input: Option, + /// When to reset checklist progress. Defaults to `Reset::Session`. #[serde(default)] pub reset: Reset, + /// If true, the first block message includes a hint to run `preview.sh`. #[serde(default)] pub allow_preview_request: bool, } +/// Parse a `config.toml` file from its string content. pub fn parse_config(path: &str, content: &str) -> crate::Result { toml::from_str(content).map_err(|e| crate::error::SteplockError::Toml { path: path.to_owned(), diff --git a/core/src/flow.rs b/core/src/flow.rs index 2177d5d..d9c47b0 100644 --- a/core/src/flow.rs +++ b/core/src/flow.rs @@ -4,20 +4,20 @@ use crate::error::{Result, SteplockError}; #[derive(Debug, Clone)] pub struct FlowGraph { - /// States reachable from [*] (the initial states). + /// States reachable from `[*]` (the initial states). pub initial: Vec, - /// Outgoing transitions per state. States that go to [*] map to vec!["[*]"]. + /// Outgoing transitions per state. States that go to `[*]` map to `vec!["[*]"]`. pub transitions: HashMap>, /// Human-readable label per state. pub labels: HashMap, - /// States with a transition to [*] (terminal states). + /// States with a transition to `[*]` (terminal states). pub terminal: HashSet, /// Topological order of non-pseudo states (for preview output). pub order: Vec, } impl FlowGraph { - /// Returns states that are not yet visited and not the pseudo [*] node. + /// Returns states that are not yet visited and not the pseudo `[*]` node. pub fn pending_after(&self, visited: &[String]) -> Vec { let visited_set: HashSet<&str> = visited.iter().map(|s| s.as_str()).collect(); self.order @@ -27,7 +27,7 @@ impl FlowGraph { .collect() } - /// Outgoing transitions from `state`, excluding [*]. + /// Outgoing transitions from `state`, excluding `[*]`. pub fn next_states(&self, state: &str) -> Vec { self.transitions .get(state) @@ -35,12 +35,14 @@ impl FlowGraph { .unwrap_or_default() } - /// True if `state` is a terminal state (transitions to [*]). + /// True if `state` is a terminal state (transitions to `[*]`). pub fn is_terminal(&self, state: &str) -> bool { self.terminal.contains(state) } } +/// Parse a Mermaid `stateDiagram-v2` diagram from its string content. +/// `path` is used only for error messages. pub fn parse_mmd(path: &str, content: &str) -> Result { let mut transitions: HashMap> = HashMap::new(); let mut labels: HashMap = HashMap::new(); diff --git a/core/src/state.rs b/core/src/state.rs index 2b3f0ee..fe8ac52 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -16,11 +16,13 @@ pub struct SessionState { } impl SessionState { + /// Returns true when `current_state` is `"[*]"` (the terminal pseudo-state). pub fn is_complete(&self) -> bool { self.current_state == "[*]" } } +/// Load and deserialize a `state.json` file. pub fn load_state(path: &Path) -> Result { let content = fs::read_to_string(path)?; Ok(serde_json::from_str(&content)?) diff --git a/schemas/session-state.schema.json b/schemas/session-state.schema.json index e2519b2..aa09f00 100644 --- a/schemas/session-state.schema.json +++ b/schemas/session-state.schema.json @@ -21,9 +21,8 @@ }, "transitions": { "type": "array", - "description": "Valid next state names from current_state. ack.sh validates $1 against this list.", - "items": { "type": "string" }, - "minItems": 1 + "description": "Valid next state names from current_state. ack.sh validates $1 against this list. Empty when checklist is complete.", + "items": { "type": "string" } }, "visited": { "type": "array", From 35036b154773b97275a8be5ba287128ad79e6670 Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Thu, 11 Jun 2026 19:00:43 +0300 Subject: [PATCH 7/7] feat: add --help/-h flag to binary --- core/src/bin/main.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index 8c2e8f7..95b4125 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -11,6 +11,9 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); fn main() { let args: Vec = env::args().skip(1).collect(); match args.as_slice() { + [flag] if flag == "--help" || flag == "-h" => { + print_help(); + } [flag] if flag == "--version" || flag == "-V" => { println!("steplock {VERSION}"); } @@ -24,12 +27,34 @@ fn main() { [] => run_hook(), _ => { eprintln!("steplock: unknown arguments"); - eprintln!("Usage: steplock [--version | init]"); + eprintln!("Run 'steplock --help' for usage."); process::exit(1); } } } +fn print_help() { + println!( + "steplock {VERSION} +Stateful quality gates for AI coding agent tool calls. + +USAGE: + steplock [COMMAND] + + With no arguments, reads a polyhook event from stdin and responds. + +COMMANDS: + init Create .steplock/checklists/ directory in the current repo + +OPTIONS: + -h, --help Print this help + -V, --version Print version + +DOCUMENTATION: + https://github.com/polyhook/steplock" + ); +} + fn cmd_init(root: &Path) -> std::io::Result<()> { let checklists = root.join(".steplock/checklists"); fs::create_dir_all(&checklists)?; @@ -147,6 +172,11 @@ reset = "session" assert!(!VERSION.is_empty()); } + #[test] + fn print_help_does_not_panic() { + print_help(); + } + #[test] fn cmd_init_creates_checklists_dir() { let tmp = TempDir::new().unwrap();