1use anyhow::{Context, Result};
2use corcept_contract::validate_value;
3use corcept_doctrine::{default_documents, validate as validate_doctrine};
4use corcept_guards::{
5 evaluate_pre_tool, evaluate_stop, extract_command, extract_path, StopVerdict,
6};
7use corcept_ledger::{ensure_ledger, read_events, verify_hash_chain_readonly};
8use corcept_memory::ensure_dirs as ensure_memory_dirs;
9use corcept_sink::{build_ledger_event, SinkDispatcher, SinkRecord};
10use corcept_types::{
11 dir_permissions_secure, operator_data_dir, project_ledger_dir, transition_for, AuthorityLevel,
12 CorceptConfig, HookEnvelope, HookOutput, LedgerEventKind,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct InitOptions {
22 pub path: PathBuf,
23 pub dry_run: bool,
24 pub force: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct InitReport {
29 pub path: PathBuf,
30 pub dry_run: bool,
31 pub created: Vec<PathBuf>,
32 pub modified: Vec<PathBuf>,
33 pub skipped: Vec<PathBuf>,
34 pub warnings: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CheckResult {
39 pub name: String,
40 pub status: String,
41 pub detail: String,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct DoctorOptions {
46 #[serde(default)]
47 pub validate_perms: bool,
48 #[serde(default)]
49 pub strict: bool,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DoctorReport {
54 pub status: String,
55 pub checks: Vec<CheckResult>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AuditReport {
60 pub status: String,
61 pub event_count: usize,
62 pub hash_chain_valid: bool,
63 pub last_event: Option<String>,
64 pub warnings: Vec<String>,
65}
66
67pub fn init_project(options: InitOptions) -> Result<InitReport> {
68 let root = options.path;
69 let mut report = InitReport {
70 path: root.clone(),
71 dry_run: options.dry_run,
72 created: vec![],
73 modified: vec![],
74 skipped: vec![],
75 warnings: vec![],
76 };
77
78 plan_dir(&root.join(".claude"), &mut report, options.dry_run)?;
79 plan_dir(
80 &root.join(".corcept").join("doctrine"),
81 &mut report,
82 options.dry_run,
83 )?;
84 plan_dir(
85 &root.join(".corcept").join("memory").join("accepted"),
86 &mut report,
87 options.dry_run,
88 )?;
89 plan_dir(
90 &root.join(".corcept").join("memory").join("candidates"),
91 &mut report,
92 options.dry_run,
93 )?;
94 plan_dir(
95 &root.join(".corcept").join("memory").join("rejected"),
96 &mut report,
97 options.dry_run,
98 )?;
99 plan_dir(
100 &root.join(".corcept").join("ledger"),
101 &mut report,
102 options.dry_run,
103 )?;
104 plan_dir(
105 &root.join(".corcept").join("reports"),
106 &mut report,
107 options.dry_run,
108 )?;
109
110 write_file(
111 &root.join(".corcept").join("config.yaml"),
112 &serde_yaml::to_string(&CorceptConfig::default())?,
113 options.force,
114 options.dry_run,
115 &mut report,
116 )?;
117 write_file(
118 &root.join(".claude").join("CLAUDE.md"),
119 render_claude_md(),
120 options.force,
121 options.dry_run,
122 &mut report,
123 )?;
124 write_file(
125 &root.join(".claude").join("settings.json"),
126 &render_project_settings()?,
127 options.force,
128 options.dry_run,
129 &mut report,
130 )?;
131
132 for (name, content) in default_documents() {
133 write_file(
134 &root.join(".corcept").join("doctrine").join(name),
135 content,
136 options.force,
137 options.dry_run,
138 &mut report,
139 )?;
140 }
141
142 write_file(
143 &root
144 .join(".corcept")
145 .join("memory")
146 .join("accepted")
147 .join("README.md"),
148 "# Accepted Memory\n\nApproved project memory lives here.\n",
149 options.force,
150 options.dry_run,
151 &mut report,
152 )?;
153 write_file(
154 &root
155 .join(".corcept")
156 .join("memory")
157 .join("candidates")
158 .join("README.md"),
159 "# Candidate Memory\n\nEvidence-backed proposed memories live here until promoted.\n",
160 options.force,
161 options.dry_run,
162 &mut report,
163 )?;
164 write_file(
165 &root
166 .join(".corcept")
167 .join("memory")
168 .join("rejected")
169 .join("README.md"),
170 "# Rejected Memory\n\nRejected or superseded memory candidates live here.\n",
171 options.force,
172 options.dry_run,
173 &mut report,
174 )?;
175
176 if !options.dry_run {
177 ensure_ledger(&root)?;
178 ensure_memory_dirs(&root)?;
179 } else {
180 report
181 .created
182 .push(root.join(".corcept").join("ledger").join("events.jsonl"));
183 }
184
185 Ok(report)
186}
187
188fn plan_dir(path: &Path, report: &mut InitReport, dry_run: bool) -> Result<()> {
189 if path.exists() {
190 return Ok(());
191 }
192 report.created.push(path.to_path_buf());
193 if !dry_run {
194 fs::create_dir_all(path)
195 .with_context(|| format!("creating directory {}", path.display()))?;
196 }
197 Ok(())
198}
199
200fn write_file(
201 path: &Path,
202 content: &str,
203 force: bool,
204 dry_run: bool,
205 report: &mut InitReport,
206) -> Result<()> {
207 if path.exists() && !force {
208 report.skipped.push(path.to_path_buf());
209 return Ok(());
210 }
211 if path.exists() {
212 report.modified.push(path.to_path_buf());
213 } else {
214 report.created.push(path.to_path_buf());
215 }
216 if !dry_run {
217 if let Some(parent) = path.parent() {
218 fs::create_dir_all(parent)?;
219 }
220 fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
221 }
222 Ok(())
223}
224
225pub fn load_config(root: impl AsRef<Path>) -> Result<CorceptConfig> {
226 let path = root.as_ref().join(".corcept").join("config.yaml");
227 if !path.exists() {
228 return Ok(CorceptConfig::default());
229 }
230 let raw =
231 fs::read_to_string(&path).with_context(|| format!("reading config {}", path.display()))?;
232 Ok(serde_yaml::from_str(&raw)?)
233}
234
235pub fn doctor(path: impl AsRef<Path>) -> Result<DoctorReport> {
236 doctor_with_options(path, DoctorOptions::default())
237}
238
239pub fn doctor_with_options(path: impl AsRef<Path>, options: DoctorOptions) -> Result<DoctorReport> {
240 let root = path.as_ref();
241 let mut checks = Vec::new();
242 push_check(
243 &mut checks,
244 "config",
245 root.join(".corcept/config.yaml").exists(),
246 "Project config exists",
247 );
248 push_check(
249 &mut checks,
250 "claude_md",
251 root.join(".claude/CLAUDE.md").exists(),
252 "Claude project instructions exist",
253 );
254 push_check(
255 &mut checks,
256 "ledger",
257 root.join(".corcept/ledger/events.jsonl").exists(),
258 "Ledger exists",
259 );
260 push_check(
261 &mut checks,
262 "memory",
263 root.join(".corcept/memory").exists(),
264 "Memory directory exists",
265 );
266
267 let doctrine_warnings = validate_doctrine(root)
268 .unwrap_or_else(|err| vec![format!("Doctrine validation failed: {err}")]);
269 push_check(
270 &mut checks,
271 "doctrine",
272 doctrine_warnings.is_empty(),
273 if doctrine_warnings.is_empty() {
274 "Doctrine validates"
275 } else {
276 "Doctrine warnings present"
277 },
278 );
279
280 let hash_valid = verify_hash_chain_readonly(root).unwrap_or(false);
281 push_check(
282 &mut checks,
283 "ledger_hash_chain",
284 hash_valid,
285 "Ledger hash chain verifies",
286 );
287
288 if options.strict {
289 let schema_ok = validate_ledger_schema(root);
290 push_check(
291 &mut checks,
292 "ledger_schema",
293 schema_ok,
294 "Ledger events validate against corcept.ledger_event.v1 schema",
295 );
296 }
297
298 if options.validate_perms {
299 let ledger_dir = project_ledger_dir(root);
300 let ledger_perms = dir_permissions_secure(&ledger_dir);
301 push_check(
302 &mut checks,
303 "ledger_dir_perms",
304 ledger_perms,
305 "Project ledger directory is owner-only (0700) or absent",
306 );
307 if let Some(op_data) = operator_data_dir() {
308 if op_data.exists() {
309 let op_perms = dir_permissions_secure(&op_data);
310 push_check(
311 &mut checks,
312 "operator_data_dir_perms",
313 op_perms,
314 "Operator data directory is owner-only (0700)",
315 );
316 }
317 }
318 }
319
320 let all_pass = checks.iter().all(|check| check.status == "pass");
321 let status = if all_pass {
322 "pass"
323 } else if options.strict {
324 "fail"
325 } else {
326 "warn"
327 }
328 .to_string();
329 Ok(DoctorReport { status, checks })
330}
331
332fn validate_ledger_schema(root: &Path) -> bool {
333 let Ok(events) = read_events(root) else {
334 return false;
335 };
336 for event in events {
337 let Ok(value) = serde_json::to_value(&event) else {
338 return false;
339 };
340 if validate_value("corcept-ledger-event-v1.schema.json", &value).is_err() {
341 return false;
342 }
343 }
344 true
345}
346
347fn push_check(checks: &mut Vec<CheckResult>, name: &str, pass: bool, detail: &str) {
348 checks.push(CheckResult {
349 name: name.to_string(),
350 status: if pass { "pass" } else { "warn" }.to_string(),
351 detail: detail.to_string(),
352 });
353}
354
355pub fn audit(path: impl AsRef<Path>) -> Result<AuditReport> {
356 let events = read_events(&path).unwrap_or_default();
357 let hash_chain_valid = verify_hash_chain_readonly(&path).unwrap_or(false);
358 let mut warnings = Vec::new();
359 if !hash_chain_valid {
360 warnings.push("Ledger hash chain is invalid or ledger is missing.".to_string());
361 }
362 let last_event = events.last().map(|event| event.event_type.clone());
363 Ok(AuditReport {
364 status: if warnings.is_empty() { "pass" } else { "warn" }.to_string(),
365 event_count: events.len(),
366 hash_chain_valid,
367 last_event,
368 warnings,
369 })
370}
371
372pub fn handle_hook(raw_json: &str, command: &str) -> Result<HookOutput> {
373 let input: HookEnvelope = serde_json::from_str(raw_json).context("parsing hook input JSON")?;
374 let cwd = input.cwd.clone().unwrap_or(std::env::current_dir()?);
375 let config = load_config(&cwd).unwrap_or_default();
376
377 match command {
378 "session-start" => {
379 append_hook_event(
380 &cwd,
381 &input,
382 "session-start",
383 LedgerEventKind::SessionStarted,
384 AuthorityLevel::L0Observe,
385 None,
386 Some("allow"),
387 Some("Session started"),
388 )?;
389 Ok(HookOutput::context("SessionStart", "CORCEPT active: doctrine, memory, guard, and audit policy loaded. Use CORCEPT skills for structured workflows."))
390 }
391 "user-prompt-submit" => {
392 let prompt = input.prompt.clone().unwrap_or_default();
393 append_hook_event(
394 &cwd,
395 &input,
396 "user-prompt-submit",
397 LedgerEventKind::PromptSubmitted,
398 AuthorityLevel::L0Observe,
399 None,
400 Some("allow"),
401 Some("Prompt received"),
402 )?;
403 let context = classify_prompt_context(&prompt);
404 Ok(HookOutput::context("UserPromptSubmit", context))
405 }
406 "pretool-guard" => {
407 let verdict = evaluate_pre_tool(&input, &config);
408 let target = extract_path(input.tool_input.as_ref())
409 .or_else(|| extract_command(input.tool_input.as_ref()));
410 append_hook_event(
411 &cwd,
412 &input,
413 "pretool-guard",
414 LedgerEventKind::ToolRequested,
415 verdict.authority_level,
416 target,
417 Some(&verdict.decision.to_string()),
418 Some(&verdict.reason),
419 )?;
420 Ok(verdict.to_hook_output())
421 }
422 "posttool-audit" => {
423 let event_kind = classify_posttool_event(&input);
424 let decision = classify_posttool_decision(&input);
425 let target = extract_path(input.tool_input.as_ref())
426 .or_else(|| extract_command(input.tool_input.as_ref()));
427 append_hook_event(
428 &cwd,
429 &input,
430 "posttool-audit",
431 event_kind,
432 AuthorityLevel::L3ExecuteLocal,
433 target,
434 Some(&decision),
435 Some("PostToolUse audited"),
436 )?;
437 Ok(HookOutput::context(
438 "PostToolUse",
439 "CORCEPT audited the completed tool call.",
440 ))
441 }
442 "stop-check" => match evaluate_stop(&cwd, input.stop_hook_active.unwrap_or(false)) {
443 StopVerdict::Allow(reason) => {
444 append_hook_event(
445 &cwd,
446 &input,
447 "stop-check",
448 LedgerEventKind::StopAllowed,
449 AuthorityLevel::L0Observe,
450 None,
451 Some("allow"),
452 Some(&reason),
453 )?;
454 Ok(HookOutput::default())
455 }
456 StopVerdict::Block(reason) => {
457 append_hook_event(
458 &cwd,
459 &input,
460 "stop-check",
461 LedgerEventKind::StopBlocked,
462 AuthorityLevel::L0Observe,
463 None,
464 Some("block"),
465 Some(&reason),
466 )?;
467 Ok(HookOutput::block(reason))
468 }
469 },
470 other => Ok(HookOutput::block(format!(
471 "Unknown CORCEPT hook command: {other}"
472 ))),
473 }
474}
475
476#[allow(clippy::too_many_arguments)]
477fn append_hook_event(
478 root: &Path,
479 input: &HookEnvelope,
480 command: &str,
481 kind: LedgerEventKind,
482 authority_level: AuthorityLevel,
483 target: Option<String>,
484 decision: Option<&str>,
485 reason: Option<&str>,
486) -> Result<()> {
487 let transition = transition_for(command, kind, decision);
488 let mut metadata = BTreeMap::new();
489 metadata.insert(
490 "transition_id".to_string(),
491 serde_json::Value::String(transition.id().to_string()),
492 );
493 if let Some(tool_input) = &input.tool_input {
494 metadata.insert("tool_input".to_string(), sanitize_value(tool_input));
495 }
496 let event = build_ledger_event(
497 input.session_id.clone(),
498 input
499 .agent_type
500 .clone()
501 .unwrap_or_else(|| "corcept-runtime".to_string()),
502 kind,
503 authority_level,
504 input.tool_name.clone(),
505 target,
506 decision.map(ToOwned::to_owned),
507 reason.map(ToOwned::to_owned),
508 metadata,
509 );
510 let correlation = input
511 .session_id
512 .clone()
513 .unwrap_or_else(|| "unknown".to_string());
514 let outcome = decision.unwrap_or("recorded");
515 let record = SinkRecord::new(correlation, kind, outcome);
516 let dispatcher = SinkDispatcher::hook_default(root);
517 dispatcher.emit_all(&record, Some(&event))?;
518 Ok(())
519}
520
521fn sanitize_value(value: &Value) -> Value {
522 match value {
523 Value::Object(map) => {
524 let sanitized = map
525 .iter()
526 .map(|(key, value)| {
527 let lower = key.to_ascii_lowercase();
528 if lower.contains("token")
529 || lower.contains("secret")
530 || lower.contains("password")
531 || lower.contains("key")
532 {
533 (key.clone(), Value::String("[REDACTED]".to_string()))
534 } else {
535 (key.clone(), sanitize_value(value))
536 }
537 })
538 .collect();
539 Value::Object(sanitized)
540 }
541 Value::Array(values) => Value::Array(values.iter().map(sanitize_value).collect()),
542 other => other.clone(),
543 }
544}
545
546fn classify_prompt_context(prompt: &str) -> String {
547 let lower = prompt.to_ascii_lowercase();
548 if [
549 "deploy",
550 "prod",
551 "production",
552 "secret",
553 "token",
554 "auth",
555 "billing",
556 "migration",
557 ]
558 .iter()
559 .any(|needle| lower.contains(needle))
560 {
561 "CORCEPT: This prompt appears security- or side-effect-sensitive. Apply doctrine, require concrete evidence, and treat production/external actions as L4.".to_string()
562 } else {
563 "CORCEPT: Use bounded diffs, explicit assumptions, and evidence-backed completion."
564 .to_string()
565 }
566}
567
568fn classify_posttool_event(input: &HookEnvelope) -> LedgerEventKind {
569 match input.tool_name.as_deref().unwrap_or_default() {
570 "Edit" | "Write" | "MultiEdit" | "NotebookEdit" => LedgerEventKind::FileModified,
571 "Bash" => {
572 let command = extract_command(input.tool_input.as_ref()).unwrap_or_default();
573 if is_test_command(&command) {
574 LedgerEventKind::TestRun
575 } else {
576 LedgerEventKind::CommandExecuted
577 }
578 }
579 _ => LedgerEventKind::ToolCompleted,
580 }
581}
582
583fn is_test_command(command: &str) -> bool {
584 let normalized = command
585 .split_whitespace()
586 .collect::<Vec<_>>()
587 .join(" ")
588 .to_ascii_lowercase();
589 let test_prefixes = [
590 "cargo test",
591 "cargo nextest",
592 "npm test",
593 "npm run test",
594 "pnpm test",
595 "pnpm run test",
596 "yarn test",
597 "bun test",
598 "pytest",
599 "python -m pytest",
600 "python3 -m pytest",
601 "go test",
602 "mvn test",
603 "gradle test",
604 "./gradlew test",
605 ];
606 test_prefixes
607 .iter()
608 .any(|prefix| normalized == *prefix || normalized.starts_with(&format!("{prefix} ")))
609}
610
611fn classify_posttool_decision(input: &HookEnvelope) -> String {
612 let exit_code = input
613 .tool_response
614 .as_ref()
615 .and_then(|value| value.get("exit_code"))
616 .and_then(|value| value.as_i64());
617 match exit_code {
618 Some(0) => "pass".to_string(),
619 Some(_) => "fail".to_string(),
620 None => "recorded".to_string(),
621 }
622}
623
624fn render_project_settings() -> Result<String> {
625 let value = json!({
626 "hooks": {
627 "SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "corcept hook session-start" }] }],
628 "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "corcept hook user-prompt-submit" }] }],
629 "PreToolUse": [{ "matcher": "Bash|Read|Grep|Glob|Edit|Write|MultiEdit|NotebookEdit|WebFetch|WebSearch", "hooks": [{ "type": "command", "command": "corcept hook pretool-guard" }] }],
630 "PostToolUse": [{ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", "hooks": [{ "type": "command", "command": "corcept hook posttool-audit" }] }],
631 "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "corcept hook stop-check" }] }]
632 }
633 });
634 Ok(serde_json::to_string_pretty(&value)?)
635}
636
637fn render_claude_md() -> &'static str {
638 r#"# CORCEPT Project Instructions
639
640You are operating inside an CORCEPT-governed project.
641
642## Authority
643
644Follow this precedence:
645
6461. Direct user instruction for the current task.
6472. Active CORCEPT doctrine.
6483. Accepted CORCEPT memory.
6494. This file.
6505. Skill or agent-local instructions.
651
652Do not promote memory or doctrine without explicit approval.
653
654## Operating rules
655
656- State assumptions before acting when scope is unclear.
657- Prefer bounded diffs.
658- Do not edit files outside approved task scope.
659- Do not claim tests passed unless you ran them or the user provided evidence.
660- Treat secrets as unreadable; identify their presence only.
661- Use CORCEPT skills for structured workflows.
662
663## Required evidence
664
665For completed coding work, report files changed, tests run, test result, known untested risks, and unresolved issues.
666"#
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use corcept_types::LedgerEventKind;
673 use serde_json::{json, Value};
674 use std::fs;
675 use std::path::PathBuf;
676
677 fn repo_root() -> PathBuf {
678 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
679 .join("../..")
680 .canonicalize()
681 .unwrap()
682 }
683
684 fn load_fixture(name: &str, cwd: &Path) -> String {
685 let raw = fs::read_to_string(repo_root().join("tests/fixtures/hooks").join(name))
686 .unwrap_or_else(|_| panic!("fixture {name}"));
687 let mut value: Value = serde_json::from_str(&raw).expect("fixture json");
688 value["cwd"] = Value::String(cwd.to_string_lossy().into_owned());
689 serde_json::to_string(&value).expect("fixture json string")
690 }
691
692 fn init_temp_project() -> tempfile::TempDir {
693 let dir = tempfile::tempdir().unwrap();
694 init_project(InitOptions {
695 path: dir.path().to_path_buf(),
696 dry_run: false,
697 force: false,
698 })
699 .unwrap();
700 dir
701 }
702
703 #[test]
704 fn dry_run_does_not_write() {
705 let dir = tempfile::tempdir().unwrap();
706 let target = dir.path().join("project");
707 let report = init_project(InitOptions {
708 path: target.clone(),
709 dry_run: true,
710 force: false,
711 })
712 .unwrap();
713 assert!(report.created.iter().any(|p| p.ends_with("config.yaml")));
714 assert!(!target.exists());
715 }
716
717 #[test]
718 fn hook_denies_dangerous_command() {
719 let dir = tempfile::tempdir().unwrap();
720 init_project(InitOptions {
721 path: dir.path().to_path_buf(),
722 dry_run: false,
723 force: false,
724 })
725 .unwrap();
726 let input = json!({
727 "session_id":"s",
728 "transcript_path":"/tmp/t.jsonl",
729 "cwd": dir.path(),
730 "hook_event_name":"PreToolUse",
731 "tool_name":"Bash",
732 "tool_input":{"command":"rm -rf /"},
733 "tool_use_id":"t"
734 });
735 let out = handle_hook(&input.to_string(), "pretool-guard").unwrap();
736 let json = serde_json::to_string(&out).unwrap();
737 assert!(json.contains("deny"));
738 }
739
740 #[test]
741 fn hook_fixture_pretool_denies_rm_rf() {
742 run_hook_fixture("pretool-bash-rm-rf.json", "pretool-guard", |out| {
743 assert!(out.contains("deny"));
744 });
745 }
746
747 #[test]
748 fn hook_fixture_pretool_denies_env_read() {
749 run_hook_fixture("pretool-read-env.json", "pretool-guard", |out| {
750 assert!(out.contains("deny"));
751 });
752 }
753
754 #[test]
755 fn hook_fixture_pretool_asks_npm_install() {
756 run_hook_fixture("pretool-bash-npm-install.json", "pretool-guard", |out| {
757 assert!(out.contains("ask"));
758 });
759 }
760
761 #[test]
762 fn hook_fixture_pretool_allows_safe_echo() {
763 run_hook_fixture("pretool-bash-safe.json", "pretool-guard", |out| {
764 assert!(!out.contains("deny") && !out.contains("ask"));
765 });
766 }
767
768 #[test]
769 fn hook_fixture_posttool_records_event() {
770 run_hook_fixture("posttool-npm-test.json", "posttool-audit", |_| {});
771 }
772
773 #[test]
774 fn hook_fixture_session_start_writes_versioned_event() {
775 let dir = init_temp_project();
776 let raw = load_fixture("session-start.json", dir.path());
777 handle_hook(&raw, "session-start").unwrap();
778 let events = read_events(dir.path()).unwrap();
779 assert!(events.iter().any(|e| {
780 LedgerEventKind::SessionStarted.matches_str(&e.event_type)
781 && e.metadata.get("transition_id").and_then(|v| v.as_str())
782 == Some("T010_session_start")
783 }));
784 }
785
786 #[test]
787 fn hook_fixture_stop_check_allows_clean_project() {
788 run_hook_fixture("stop-check.json", "stop-check", |out| {
789 assert!(!out.contains("block"));
790 });
791 }
792
793 fn run_hook_fixture(name: &str, command: &str, assert_out: impl FnOnce(&str)) {
794 let dir = init_temp_project();
795 let raw = load_fixture(name, dir.path());
796 let out = handle_hook(&raw, command).unwrap();
797 let json = serde_json::to_string(&out).unwrap();
798 assert_out(&json);
799 assert!(!read_events(dir.path()).unwrap().is_empty());
800 }
801}