//! Rust counterparts of the edge cases in the public repo's //! `tests/hook.test.mjs` that the oracle corpus does not exercise: path //! classifiers, config merging, cache GC, git exclude, rendering and quoting, //! target expansion, and the run flows (tiering, suppression, oversized files, //! symlinked roots, umbrella launches, Stop pass, before-edit shell shapes, //! hook-admin scoping). Findings come from the real regex engine, so fixture //! bodies carry known slop: `gradient-text` (immediate tier) and `side-tab` //! (deferred tier). use std::collections::HashMap; use std::path::PathBuf; use impeccable_common::{jsp, Io}; use impeccable_core::findings::{finding, Finding}; use impeccable_detect::MissingHtmlEngine; use impeccable_hook::hook_lib::*; use impeccable_hook::{admin, before_edit, hook}; use serde_json::{json, Map, Value}; static HTML: MissingHtmlEngine = MissingHtmlEngine; struct Tmp(PathBuf); impl Tmp { fn new() -> Tmp { // pid + nanos alone can collide when the parallel runner starts two // tests inside one clock tick (seen as a paired flake under full // workspace load); the per-process counter makes each dir unique. static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let base = std::env::temp_dir().join(format!( "impeccable-hook-rs-{}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(), SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) )); std::fs::create_dir_all(&base).unwrap(); // Canonical path so the JS-style path helpers and the fs agree on macOS. // Like Node's `realpathSync`, without the `\\?\` verbatim prefix Windows // adds: the kernel takes a verbatim path literally, so a `/` joined // under it would not resolve. let real = std::fs::canonicalize(&base).unwrap().to_string_lossy().into_owned(); Tmp(PathBuf::from(real.strip_prefix(r"\\?\").unwrap_or(&real))) } fn path(&self) -> String { self.0.to_string_lossy().into_owned() } fn write(&self, rel: &str, body: &str) -> String { // `PathBuf::join` keeps the `/` inside `rel`, which leaves a mixed-form // path on Windows; join the way the hook's own path helpers do, so the // returned path is what the hook resolves a relative target to. let abs = jsp::join(&[&self.path(), rel]); let p = std::path::Path::new(&abs).to_path_buf(); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, body).unwrap(); abs } fn exists(&self, rel: &str) -> bool { self.0.join(rel).exists() } fn read(&self, rel: &str) -> String { std::fs::read_to_string(self.0.join(rel)).unwrap() } } impl Drop for Tmp { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } /// The shared config path as the admin verbs print it: relative to the /// project, in the host's path form (backslashes on Windows, as `path.relative` /// renders it there). fn shared_config_rel() -> String { jsp::join(&[".impeccable", "config.json"]) } /// The local config path as the admin verbs print it, in the same host form. fn local_config_rel() -> String { jsp::join(&[".impeccable", "config.local.json"]) } fn rt_with(cwd: &str, env: HashMap) -> Runtime<'static> { Runtime::new( cwd.to_string(), env, "/impeccable".to_string(), "/opt/bin/impeccable", &HTML, ) } fn rt(cwd: &str) -> Runtime<'static> { rt_with(cwd, HashMap::new()) } fn env(pairs: &[(&str, &str)]) -> HashMap { pairs .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect() } fn f(id: &str, line: f64, name: &str, description: &str, snippet: &str) -> Finding { let mut x = finding(id, "src/Card.tsx", snippet, line); x.name = name.to_string(); x.description = description.to_string(); x } fn edit_event(cwd: &str, file: &str, session: &str) -> String { json!({ "session_id": session, "cwd": cwd, "hook_event_name": "PostToolUse", "tool_name": "Edit", "tool_input": { "file_path": file }, }) .to_string() } fn stop_event(cwd: &str, session: &str) -> String { json!({ "session_id": session, "cwd": cwd, "hook_event_name": "Stop", "stop_hook_active": false }).to_string() } const GRADIENT_CSS: &str = ".title { background: linear-gradient(90deg, #f472b6, #a78bfa); -webkit-background-clip: text; color: transparent; }\n"; const SIDE_TAB_CSS: &str = ".card { border-left: 4px solid #6366f1; border-radius: 8px; }\n"; fn monorepo_design_fixture(root_design: bool) -> Tmp { let t = Tmp::new(); t.write("package.json", r#"{"workspaces":["apps/*"]}"#); t.write("apps/a/package.json", "{}"); t.write("apps/b/package.json", "{}"); t.write("apps/a/DESIGN.md", "---\ncolors:\n primary: '#112233'\n---\n"); if root_design { t.write("DESIGN.md", "---\ncolors:\n primary: '#224466'\n---\n"); } t.write(".impeccable/config.json", r#"{"hook":{"perEditRules":"all"},"detector":{"advisoryRules":"include"}}"#); t } // Run identical cases through all three hook entry points. The probe that is // allowed by the repo palette must still fail against app A's own palette. fn check_monorepo_design_hook(mode: &str) { for (root_design, app, color, expected) in [ (false, "a", "#ff00aa", true), (false, "b", "#ff00aa", false), (true, "a", "#224466", true), (true, "b", "#ff00aa", true), (true, "b", "#224466", false), ] { let t = monorepo_design_fixture(root_design); let cwd = t.path(); let source = format!(".probe {{ color: {color}; }}\n"); let file = t.write(&format!("apps/{app}/src/probe.css"), &source); let r = rt(&cwd); let out = match mode { "post" => hook::run_hook(&r, &edit_event(&cwd, &file, "s1")).stdout, "before" => { // A proposed new file must resolve its owning app too. std::fs::remove_file(&file).unwrap(); hbe(&r, &cursor(&cwd, "Write", json!({ "file_path": file, "content": source, }))).0 } "stop" => { let mut cache = read_cache(&cwd); touch_file(&mut cache, "s1", &file); persist_cache(&r, &cwd, &cache); hook::run_stop_hook(&r, &stop_event(&cwd, "s1")).stdout } _ => unreachable!(), }; assert_eq!(out.contains("design-system-color"), expected, "{mode}: root_design={root_design}, app={app}, color={color}: {out}"); assert!(!t.exists(&format!("apps/{app}/.impeccable/hook.cache.json")), "design resolution must not relocate hook state"); } } #[test] fn monorepo_design_post_edit() { check_monorepo_design_hook("post"); } #[test] fn monorepo_design_before_edit() { check_monorepo_design_hook("before"); } #[test] fn monorepo_design_stop() { check_monorepo_design_hook("stop"); } #[test] fn monorepo_design_document_locations_and_sidecars() { for location in ["DESIGN.md", "docs/DESIGN.md", ".agents/context/DESIGN.md"] { let t = monorepo_design_fixture(true); let cwd = t.path(); let file = t.write("apps/b/src/probe.css", ".probe {}\n"); let md = t.write(&format!("apps/b/{location}"), "---\ntypography:\n body:\n fontFamily: Georgia\nrounded:\n md: 8px\ncolors:\n primary: '#abcdef'\n---\n"); let sidecar = t.write("apps/b/.impeccable/design.json", "{}"); let scan = design_system_options_for_file(&rt(&cwd), &read_config(&cwd), &cwd, &file); let ds = scan.design_system.as_ref().unwrap(); assert_eq!(ds.source_path.as_deref(), Some(md.as_str())); assert_eq!(ds.sidecar_path.as_deref(), Some(sidecar.as_str())); let findings = detector_detect_text( ".probe { color: #ff0000; font-family: Verdana; border-radius: 19px; }", &file, &scan); for rule in ["design-system-color", "design-system-font", "design-system-radius"] { assert!(findings.iter().any(|f| f.antipattern == rule), "{location}: {rule}"); } let allowed = detector_detect_text( ".probe { color: #abcdef; font-family: Georgia; border-radius: 8px; }", &file, &scan); assert!(allowed.iter().all(|f| !f.antipattern.starts_with("design-system-"))); } } #[test] fn monorepo_design_local_document_and_disabled_config_do_not_inherit() { let t = monorepo_design_fixture(true); let cwd = t.path(); let file = t.write("apps/a/src/probe.css", ".probe {}\n"); t.write("apps/a/DESIGN.md", "# App-specific prose, with no machine-readable tokens\n"); let r = rt(&cwd); let mut config = read_config(&cwd); assert!(design_system_options_for_file(&r, &config, &cwd, &file).design_system.is_none()); config.design_system_enabled = false; let sibling = t.write("apps/b/src/probe.css", ".probe {}\n"); assert!(design_system_options_for_file(&r, &config, &cwd, &sibling).design_system.is_none()); } #[test] fn monorepo_design_batch_notes_follow_the_displayed_file() { for mode in ["post-fresh", "post-pending", "post-clean", "stop"] { for stale_app in ["a", "b"] { let t = monorepo_design_fixture(true); let cwd = t.path(); let source = if mode == "post-clean" { ".probe { color: #112233; }" } else { ".probe { color: #ff00aa; }" }; let a = t.write("apps/a/src/probe.css", source); let b = t.write("apps/b/src/probe.css", ".probe { color: #224466; }"); let r = rt(&cwd); if mode == "post-pending" { hook::run_hook(&r, &edit_event(&cwd, &a, "s1")); } let sidecar = t.write(if stale_app == "a" { "apps/a/.impeccable/design.json" } else { ".impeccable/design.json" }, "{}"); std::fs::File::options().write(true).open(sidecar).unwrap() .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000)).unwrap(); let out = if mode == "stop" { let mut cache = read_cache(&cwd); touch_file(&mut cache, "s1", &a); touch_file(&mut cache, "s1", &b); persist_cache(&r, &cwd, &cache); hook::run_stop_hook(&r, &stop_event(&cwd, "s1")).stdout } else { let event = json!({"session_id":"s1", "cwd":cwd, "hook_event_name":"PostToolUse", "tool_name":"apply_patch", "tool_input":{"command":format!( "*** Begin Patch\n*** Update File: {a}\n*** Update File: {b}\n*** End Patch")}}); hook::run_hook(&r, &event.to_string()).stdout }; assert!(out.contains("apps/a/src/probe.css"), "{mode}: {out}"); assert_eq!(out.contains("DESIGN.md is newer"), stale_app == "a", "{mode}: {out}"); } } } fn edit_with_original(cwd: &str, file: &str, session: &str, before: &str, old: &str, new: &str) -> String { json!({ "session_id": session, "cwd": cwd, "hook_event_name": "PostToolUse", "tool_name": "Edit", "tool_input": {"file_path": file, "old_string": old, "new_string": new}, "tool_response": {"filePath": file, "originalFile": before, "oldString": old, "newString": new, "replaceAll": false, "userModified": false}, }).to_string() } #[test] fn stop_baseline_import_only_edit_does_not_blame_existing_font() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); // This is the actual working-tree preimage, not HEAD (which might differ). let before = "import dead from 'dead';\nconst report = ``;\n"; let after = before.replacen("import dead from 'dead';\n", "", 1); let file = t.write("query.ts", &after); let r = rt(&cwd); assert!(detector_detect_text(before, &file, &HookScanOptions::default()).iter().any(|f| f.antipattern == "overused-font")); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", before, "import dead from 'dead';\n", "")); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(stop.stdout.is_empty(), "{}", stop.stdout); assert_eq!(stop.audit["preExistingFindings"], json!(1)); assert!(!t.read(".impeccable/hook.cache.json").contains("const report"), "do not persist source contents"); assert!(!t.exists(".impeccable/config.local.json"), "baseline is not an ignore"); assert!(detector_detect_text(&after, &file, &HookScanOptions::default()).iter().any(|f| f.antipattern == "overused-font"), "explicit scans stay unchanged"); } #[test] fn stop_baseline_reports_new_findings_and_keeps_first_preimage() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let file = t.write("card.css", SIDE_TAB_CSS); let r = rt(&cwd); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", ".card {}\n", ".card {}\n", SIDE_TAB_CSS)); let second = format!("/* later */\n{SIDE_TAB_CSS}"); t.write("card.css", &second); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", SIDE_TAB_CSS, SIDE_TAB_CSS, &second)); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(stop.stdout.contains("[side-tab]")); assert!(stop.stdout.contains("[new]"), "{}", stop.stdout); assert_eq!(stop.audit["newFindings"], json!(1)); } #[test] fn stop_baseline_missing_or_mismatched_preimage_stays_unknown() { for original in [None, Some("not the actual preimage")] { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let file = t.write("card.css", SIDE_TAB_CSS); let r = rt(&cwd); let event = original.map(|before| edit_with_original(&cwd, &file, "s1", before, ".card {}", SIDE_TAB_CSS)) .unwrap_or_else(|| edit_event(&cwd, &file, "s1")); hook::run_hook(&r, &event); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(stop.stdout.contains("[attribution unknown]"), "{}", stop.stdout); assert!(stop.stdout.contains("may predate this session")); assert_eq!(stop.audit["unknownFindings"], json!(1)); } } #[test] fn stop_baseline_existing_debt_fixed_then_reintroduced_is_new() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let clean = ".card {}\n"; let file = t.write("card.css", clean); let r = rt(&cwd); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", SIDE_TAB_CSS, SIDE_TAB_CSS, clean)); t.write("card.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", clean, clean, SIDE_TAB_CSS)); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(stop.stdout.contains("[new]"), "{}", stop.stdout); assert_eq!(stop.audit["preExistingFindings"], json!(0)); } #[test] fn stop_baseline_late_preimage_does_not_relabel_unknown_debt() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let file = t.write("card.css", SIDE_TAB_CSS); let r = rt(&cwd); hook::run_hook(&r, &edit_event(&cwd, &file, "s1")); let second = format!("/* later */\n{SIDE_TAB_CSS}"); t.write("card.css", &second); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", SIDE_TAB_CSS, SIDE_TAB_CSS, &second)); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(stop.audit["unknownFindings"], json!(1)); assert_eq!(stop.audit["preExistingFindings"], json!(0)); } #[test] fn stop_baseline_keeps_indirect_stylesheet_findings_unknown() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); t.write("src/styles.css", SIDE_TAB_CSS); let before = "import './styles.css';\nexport const Card = () =>
Before
;\n"; let after = before.replace("Before", "After"); let file = t.write("src/Card.tsx", &after); let r = rt(&cwd); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", before, "Before", "After")); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(stop.stdout.contains("[side-tab]"), "{}", stop.stdout); assert_eq!(stop.audit["unknownFindings"], json!(1)); assert_eq!(stop.audit["preExistingFindings"], json!(0)); } #[test] fn stop_baseline_extra_identical_occurrence_is_not_suppressed() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); // The text detector deduplicates identical snippets within two lines. let after = format!("{SIDE_TAB_CSS}\n\n\n{SIDE_TAB_CSS}"); let file = t.write("card.css", &after); let r = rt(&cwd); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", SIDE_TAB_CSS, SIDE_TAB_CSS, &after)); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(stop.audit["preExistingFindings"], json!(1)); assert_eq!(stop.audit["newFindings"], json!(1)); } #[test] fn stop_baseline_write_create_is_new_but_missing_update_preimage_is_unknown() { for (kind, expected) in [("create", "newFindings"), ("update", "unknownFindings")] { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let file = t.write("card.css", SIDE_TAB_CSS); let r = rt(&cwd); let event = json!({"cwd": cwd, "session_id": "s1", "tool_name": "Write", "tool_input": {"file_path": file, "content": SIDE_TAB_CSS}, "tool_response": {"type": kind, "filePath": file, "content": SIDE_TAB_CSS, "originalFile": null}}).to_string(); hook::run_hook(&r, &event); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(stop.audit[expected], json!(1)); } } #[test] fn stop_baseline_unknown_notice_respects_small_output_budget() { for (budget, stale) in [(500, false), (500, true), (8000, true)] { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); t.write(".impeccable/config.json", &json!({"hook":{"limits":{"maxChars":budget}}}).to_string()); let file = t.write("card.css", SIDE_TAB_CSS); let r = rt(&cwd); hook::run_hook(&r, &edit_event(&cwd, &file, "s1")); if stale { // Make the notice eligible only at Stop; no sleeps or clock races. t.write("DESIGN.md", "---\nname: Test\n---\n"); let sidecar = t.write(".impeccable/design.json", "{}"); std::fs::File::options().write(true).open(sidecar).unwrap() .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000)).unwrap(); assert!(design_system_options(&read_config(&cwd), &cwd).md_newer_than_json()); } let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); let output: Value = serde_json::from_str(&stop.stdout).unwrap(); let text = output["hookSpecificOutput"]["additionalContext"].as_str().unwrap(); assert!(text.encode_utf16().count() <= budget, "{text}"); assert!(text.contains("may predate this session")); assert!(text.contains("[side-tab]"), "{text}"); assert!(text.contains("[attribution unknown]"), "{text}"); assert!(text.contains("card.css"), "{text}"); if stale { assert_eq!(text.contains("DESIGN.md is newer"), budget > 500, "{text}"); let cache: Value = serde_json::from_str(&t.read(".impeccable/hook.cache.json")).unwrap(); assert_eq!(cache["sessions"]["s1"]["designNoteShown"] == json!(true), budget > 500); } } } #[test] fn stop_baseline_deduplicated_unknown_does_not_add_notice_to_new_finding() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let r = rt(&cwd); let old = t.write("old/card.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_event(&cwd, &old, "s1")); assert!(hook::run_stop_hook(&r, &stop_event(&cwd, "s1")).stdout.contains("[attribution unknown]")); let new = t.write("new/card.css", SIDE_TAB_CSS); // Use a verified create event (an empty Edit preimage is not trusted). let create = json!({"cwd": cwd, "session_id": "s1", "tool_name": "Write", "tool_input": {"file_path": new, "content": SIDE_TAB_CSS}, "tool_response": {"type": "create", "filePath": new, "content": SIDE_TAB_CSS, "originalFile": null}}).to_string(); hook::run_hook(&r, &create); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(stop.audit["unknownFindings"], json!(1), "audit retains the full scan"); assert!(stop.stdout.contains("[new]"), "{}", stop.stdout); assert!(!stop.stdout.contains("may predate this session"), "{}", stop.stdout); } #[test] fn stop_baseline_capped_unknown_does_not_add_notice_to_new_finding() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); t.write(".impeccable/config.json", r#"{"hook":{"limits":{"maxFindings":1}}}"#); let r = rt(&cwd); let new = t.write("new/card.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_with_original(&cwd, &new, "s1", ".card {}", ".card {}", SIDE_TAB_CSS)); let old = t.write("old/card.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_event(&cwd, &old, "s1")); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(stop.audit["unknownFindings"], json!(1)); assert!(stop.stdout.contains("[new]"), "{}", stop.stdout); assert!(!stop.stdout.contains("[attribution unknown]"), "{}", stop.stdout); assert!(!stop.stdout.contains("may predate this session"), "{}", stop.stdout); } #[test] fn stop_baseline_small_grouped_output_keeps_finding_and_attribution() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); t.write(".impeccable/config.json", r#"{"hook":{"limits":{"maxChars":500}}}"#); let r = rt(&cwd); for path in ["one/card.css", "two/card.css"] { let file = t.write(path, SIDE_TAB_CSS); hook::run_hook(&r, &edit_event(&cwd, &file, "s1")); } let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); let output: Value = serde_json::from_str(&stop.stdout).unwrap(); let text = output["hookSpecificOutput"]["additionalContext"].as_str().unwrap(); assert!(text.encode_utf16().count() <= 500, "{text}"); assert!(text.contains("[side-tab]"), "{text}"); assert!(text.contains("[attribution unknown]"), "{text}"); assert!(text.contains("may predate this session"), "{text}"); } #[test] fn stop_baseline_dropped_notice_reclaims_its_rendering_budget() { for max_findings in [1, 5] { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); t.write(".impeccable/config.json", &json!({"hook":{"limits":{"maxChars":500,"maxFindings":max_findings}}}).to_string()); let r = rt(&cwd); let new = t.write("new/card.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_with_original(&cwd, &new, "s1", ".card {}", ".card {}", SIDE_TAB_CSS)); let old = t.write("old/card.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_event(&cwd, &old, "s1")); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); let output: Value = serde_json::from_str(&stop.stdout).unwrap(); let text = output["hookSpecificOutput"]["additionalContext"].as_str().unwrap(); let groups: Vec = [(new, "[new]"), (old, "[attribution unknown]")].into_iter().map(|(file_path, label)| { let mut findings = detector_detect_text(SIDE_TAB_CSS, &file_path, &HookScanOptions::default()); for f in &mut findings { f.name = format!("{label} {}", f.name); } Group { file_path, findings } }).collect(); let mut config = read_config(&cwd); // Unknown is not displayed at this budget. All available space goes // to the known-new prefix, rather than a discarded notice. config.limits.max_findings = 1.0; let expected = render_grouped_template(&r, &groups, &config, &RenderOpts { cwd: Some(cwd), short_footer: false, reserve_chars: 0.0, }); assert_eq!(text, expected, "maxFindings={max_findings}"); assert!(text.contains("[new] Side-tab accent border"), "{text}"); assert!(!text.contains("may predate this session"), "{text}"); } } #[test] fn stop_baseline_uses_dirty_worktree_not_git_head() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); t.write("card.css", ".card {}\n"); std::fs::create_dir(t.0.join("empty-hooks")).unwrap(); let hooks = format!("core.hooksPath={}/empty-hooks", cwd); let git = |args: &[&str]| { let result = std::process::Command::new("git").current_dir(&t.0) .args(["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "-c", &hooks]) .args(args).output().unwrap(); assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); }; git(&["init", "--quiet"]); git(&["add", "card.css", "package.json"]); git(&["commit", "--quiet", "-m", "clean baseline"]); // The user introduced this debt before the agent session; HEAD is clean. let before = format!("/* unrelated */\n{SIDE_TAB_CSS}"); let file = t.write("card.css", SIDE_TAB_CSS); let r = rt(&cwd); hook::run_hook(&r, &edit_with_original(&cwd, &file, "s1", &before, "/* unrelated */\n", "")); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(stop.audit["preExistingFindings"], json!(1)); assert!(stop.stdout.is_empty()); } #[test] fn stop_baseline_untrusted_shapes_do_not_suppress_findings() { for variant in ["modified", "wrong-path", "no-session", "oversized", "ambiguous", "other-provider"] { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let file = t.write("card.css", SIDE_TAB_CSS); let mut event: Value = serde_json::from_str(&edit_with_original(&cwd, &file, "s1", SIDE_TAB_CSS, SIDE_TAB_CSS, SIDE_TAB_CSS)).unwrap(); match variant { "modified" => event["tool_response"]["userModified"] = json!(true), "wrong-path" => event["tool_response"]["filePath"] = json!("another.css"), "no-session" => event["session_id"] = Value::Null, "oversized" => event["tool_response"]["originalFile"] = json!("x".repeat(512 * 1024 + 1)), "ambiguous" => { let repeated = SIDE_TAB_CSS.repeat(2); t.write("card.css", &repeated); event["tool_response"]["originalFile"] = json!(repeated); } _ => {}, } let r = if variant == "other-provider" { rt_with(&cwd, env(&[("IMPECCABLE_HOOK_HARNESS", "codex")])) } else { rt(&cwd) }; hook::run_hook(&r, &event.to_string()); let session = if variant == "no-session" { "unknown" } else { "s1" }; let stop = hook::run_stop_hook(&r, &stop_event(&cwd, session)); assert!(stop.stdout.contains("[attribution unknown]"), "{variant}: {}", stop.stdout); assert_eq!(stop.audit["preExistingFindings"], json!(0), "{variant}"); } } #[test] fn stop_baseline_scan_suppression_discards_exemptions() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let file = t.write("card.css", SIDE_TAB_CSS); let r = rt(&cwd); let event = edit_with_original(&cwd, &file, "s1", SIDE_TAB_CSS, SIDE_TAB_CSS, SIDE_TAB_CSS); for _ in 0..=EDIT_COUNT_THRESHOLD { hook::run_hook(&r, &event); } let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(stop.audit["unknownFindings"], json!(1)); assert_eq!(stop.audit["preExistingFindings"], json!(0)); } fn audit_str<'a>(a: &'a Map, k: &str) -> Option<&'a str> { a.get(k).and_then(Value::as_str) } // ── classifiers ─────────────────────────────────────────────────────────── #[test] fn truthy_and_depth() { for v in ["1", "true", "TRUE", "yes", "YES", "on", "On"] { assert!(truthy(Some(v)), "{v}"); } for v in ["", "0", "false", "no", "off", "yep"] { assert!(!truthy(Some(v)), "{v}"); } assert!(!truthy(None)); assert!(depth_is_set(Some("2"))); assert!(depth_is_set(Some(" 1 "))); assert!(!depth_is_set(Some("0"))); assert!(!depth_is_set(Some(""))); assert!(!depth_is_set(None)); } #[test] fn sensitive_and_generated_paths() { for p in [ "/x/.env", "/x/.env.production", "/x/server.pem", "/x/id_rsa", "/x/id_rsa.pub", "/x/api-secret.json", "/x/client_secret.ts", "/x/credentials.yml", "/x/.git/config", "/x/secret.json", ] { assert!(is_sensitive_path(p), "expected sensitive: {p}"); } for p in [ "/x/src/Card.tsx", "/x/app/page.html", "/x/styles/main.css", "/x/src/CredentialForm.tsx", "/x/src/SecretPage.jsx", "/x/src/secretary-dashboard.vue", "/x/src/credentials-panel.tsx", "/x/secretx.json", ] { assert!(!is_sensitive_path(p), "unexpected sensitive: {p}"); } for p in [ "/x/src/foo.generated.tsx", "/x/types.d.ts", "/x/bundle.min.js", "/x/node_modules/lib/index.tsx", "/x/dist/Card.tsx", "/x/build/index.html", "/x/pkg.lock.json", "/x/.next/server.js", "/x/coverage/report.html", "/x/site/public/js/generated/counts.js", "/x/src/generated/schema.ts", ] { assert!(is_generated_path(p), "expected generated: {p}"); } for p in [ "/x/src/generateReport.ts", "/x/src/generated-utils.ts", "/x/src/components/CodeGenerator.tsx", "/x/src/ui/regenerate-button.jsx", ] { assert!(!is_generated_path(p), "unexpected generated: {p}"); } } #[cfg(unix)] #[test] fn inside_project_handles_symlinks_and_unwritten_files() { let t = Tmp::new(); let root = t.path(); let r = rt(&root); let file = t.write("real/src/Card.tsx", "noop"); let real = format!("{root}/real"); let link = format!("{root}/link"); std::os::unix::fs::symlink(&real, &link).unwrap(); assert!(is_scan_target_inside_project(&r, &file, &link)); assert!(is_scan_target_inside_project( &r, &format!("{link}/src/Card.tsx"), &real )); assert!(is_scan_target_inside_project( &r, &format!("{link}/src/New.tsx"), &real )); assert!(is_scan_target_inside_project( &r, &format!("{real}/deep/New.tsx"), &link )); assert!(!is_scan_target_inside_project( &r, &format!("{root}/elsewhere/New.tsx"), &real )); assert!(is_scan_target_inside_project(&r, &real, &real)); assert!(!is_scan_target_inside_project(&r, "", &real)); assert!(!is_scan_target_inside_project(&r, &file, "")); } // ── config ──────────────────────────────────────────────────────────────── #[test] fn read_config_merges_shared_then_local_and_legacy_keys() { let t = Tmp::new(); let cwd = t.path(); assert_eq!(read_config(&cwd), HookConfig::default()); t.write( ".impeccable/config.json", r#"{"hook":{"enabled":true,"quiet":true,"auditLog":" log.ndjson ","perEditRules":"all","ignoreRules":["a"],"limits":{"maxFindings":2,"maxChars":100,"maxFileBytes":-1}},"detector":{"ignoreRules":["b"],"ignoreFiles":["x/**"],"extensions":[".blade.php",{"ext":"heex","engine":"text"}],"advisoryRules":"include","designSystem":{"enabled":false}}}"#, ); t.write( ".impeccable/config.local.json", r#"{"hook":{"enabled":false},"detector":{"ignoreRules":["a","c"],"extensions":[{"ext":".heex","engine":"html"}],"ignoreValues":[{"rule":"Overused-Font","value":"\"Inter\"","file":"a.css","files":["b.css","a.css"],"createdAt":"2026","reason":" r "}]}}"#, ); let c = read_config(&cwd); assert!(!c.enabled); assert!(c.quiet); assert_eq!(c.audit_log.as_deref(), Some("log.ndjson")); assert_eq!(c.per_edit_rules, "all"); assert_eq!(c.ignore_rules, vec!["a", "b", "c"]); assert_eq!(c.ignore_files, vec!["x/**"]); assert_eq!(c.advisory_rules, "include"); assert!(!c.design_system_enabled); assert_eq!(c.limits.max_findings, 2.0); assert_eq!(c.limits.max_chars, 100.0); assert_eq!( c.limits.max_file_bytes, 131072.0, "non-positive keeps the default" ); assert_eq!( c.extensions .iter() .map(|e| (e.ext.as_str(), e.engine.as_str())) .collect::>(), vec![(".blade.php", "html"), (".heex", "html")], "local override wins per ext, string entries default to html" ); assert_eq!(c.ignore_values.len(), 1); let e = &c.ignore_values[0]; assert_eq!( (e.rule.as_str(), e.value.as_str()), ("overused-font", "inter") ); assert_eq!( e.files.as_ref().unwrap(), &vec!["a.css".to_string(), "b.css".to_string()] ); assert_eq!(e.reason.as_deref(), Some("r")); // malformed local config is ignored, shared survives t.write(".impeccable/config.local.json", "{ nope"); let c = read_config(&cwd); assert!(c.enabled); assert_eq!(c.ignore_rules, vec!["a", "b"]); } #[test] fn configured_extensions_match_suffixes() { let exts = normalize_extension_entries(&[ json!(".php"), json!({"ext": "blade.php", "engine": "html"}), json!({"ext": ".HTML.erb", "engine": "text"}), ]); assert_eq!( match_configured_extension("/x/show.blade.php", &exts) .unwrap() .ext, ".blade.php" ); assert_eq!( match_configured_extension("/x/SHOW.HTML.ERB", &exts) .unwrap() .engine, "text" ); assert_eq!( match_configured_extension("/x/a.php", &exts).unwrap().ext, ".php" ); assert!( match_configured_extension("/x/.php", &exts).is_none(), "bare dotfile name is not a template" ); assert!(match_configured_extension("/x/a.tsx", &exts).is_none()); assert!(match_configured_extension("/x/a.php", &[]).is_none()); } // ── cache ───────────────────────────────────────────────────────────────── #[test] fn cache_round_trip_and_gc() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let mut cache = read_cache(&cwd); assert_eq!(bump_edit_count(&mut cache, "s", "/x/a.tsx"), 1.0); assert_eq!(bump_edit_count(&mut cache, "s", "/x/a.tsx"), 2.0); let g = f("gradient-text", 3.0, "G", "d", "s"); remember_findings(&mut cache, "s", "/x/a.tsx", &[g.clone()]); assert!(persist_cache(&r, &cwd, &cache)); let back = read_cache(&cwd); let entry = &back["sessions"]["s"]["files"]["/x/a.tsx"]; assert_eq!(entry["editCount"], json!(2)); assert_eq!(entry["findings"], json!(["gradient-text:3"])); let mut cache2 = back.clone(); assert!(dedupe_against_cache(&[g.clone()], &mut cache2, "s", "/x/a.tsx").is_empty()); // same-line value-specific findings stay distinct let mut a = f("overused-font", 1.0, "O", "d", "font-family: Inter"); a.extras.insert("ignoreValue".into(), json!("Inter")); let mut b = a.clone(); b.extras.insert("ignoreValue".into(), json!("Roboto")); assert_ne!(finding_cache_key(&a), finding_cache_key(&b)); // gc: 10 sessions -> newest 8 kept let mut big = read_cache(&cwd); for i in 0..10 { let s = ensure_session(&mut big, &format!("s{i}")); s.insert("updatedAt".into(), json!(1000 + i)); } assert!(persist_cache(&r, &cwd, &big)); let kept = read_cache(&cwd); let ids: Vec<&String> = kept["sessions"].as_object().unwrap().keys().collect(); assert_eq!(ids.len(), 8, "11 sessions collapse to the 8 newest"); for dropped in ["s0", "s1", "s2"] { assert!(!ids.contains(&&dropped.to_string()), "{dropped} is oldest"); } assert_eq!(ids[0], "s", "the freshly touched session sorts first"); assert_eq!(ids[1], "s9"); } #[test] fn git_excludes_land_in_info_exclude_not_gitignore() { let t = Tmp::new(); let root = t.path(); std::fs::create_dir_all(t.0.join(".git")).unwrap(); t.write(".gitignore", "node_modules\n"); let r = rt(&root); let res = ensure_hook_git_excludes(&r, &root); assert_eq!(res.mode, "git-info-exclude"); assert!(res.changed); let ex = t.read(".git/info/exclude"); assert_eq!( ex, "# impeccable-hook-ignore-start .\n.impeccable/hook.cache.json\n.impeccable/hook.pending.json\n.impeccable/config.local.json\n# impeccable-hook-ignore-end .\n" ); assert_eq!(t.read(".gitignore"), "node_modules\n"); let again = ensure_hook_git_excludes(&r, &root); assert!(!again.changed, "idempotent"); // a nested cwd gets a prefixed block appended after existing content std::fs::create_dir_all(t.0.join("apps/web")).unwrap(); let nested = format!("{root}/apps/web"); let res = ensure_hook_git_excludes(&r, &nested); assert_eq!(res.patterns[0], "apps/web/.impeccable/hook.cache.json"); let ex = t.read(".git/info/exclude"); assert!( ex.contains("# impeccable-hook-ignore-end .\n\n# impeccable-hook-ignore-start apps/web\n") ); // no repo at all let t2 = Tmp::new(); assert_eq!( ensure_hook_git_excludes(&rt(&t2.path()), &t2.path()).mode, "none" ); } // ── filtering ───────────────────────────────────────────────────────────── #[test] fn filter_findings_rules_advisory_and_values() { let mut c = HookConfig::default(); let em = f("em-dash-overuse", 1.0, "E", "d", "s"); let side = f("side-tab", 1.0, "S", "d", "s"); let mut font = f( "overused-font", 2.0, "O", "d", "body { font-family: \"Inter\", sans-serif; }", ); font.file = "/p/src/a.css".into(); assert_eq!( filter_findings(vec![em.clone(), side.clone()], &c).len(), 1, "advisory dropped by default" ); c.advisory_rules = "include".into(); assert_eq!(filter_findings(vec![em.clone(), side.clone()], &c).len(), 2); c.ignore_rules = vec!["Side-Tab".into()]; assert_eq!( filter_findings(vec![side.clone(), font.clone()], &c).len(), 1 ); c.ignore_rules.clear(); c.ignore_values = impeccable_detect::config::normalize_ignore_value_entries(&[ json!({"rule": "overused-font", "value": "inter"}), ]); assert!(filter_findings(vec![font.clone()], &c).is_empty()); c.ignore_values = impeccable_detect::config::normalize_ignore_value_entries(&[ json!({"rule": "overused-font", "value": "*", "files": ["src/*.css"]}), ]); assert!( filter_findings(vec![font.clone()], &c).is_empty(), "wildcard scoped to a suffix glob" ); c.ignore_values = impeccable_detect::config::normalize_ignore_value_entries(&[ json!({"rule": "overused-font", "value": "*"}), ]); assert_eq!( filter_findings(vec![font.clone()], &c).len(), 1, "bare wildcard never matches" ); let (imm, def) = split_findings_by_tier(vec![f("gradient-text", 1.0, "G", "d", "s"), side.clone()]); assert_eq!(imm[0].antipattern, "gradient-text"); assert_eq!(def[0].antipattern, "side-tab"); assert!(per_edit_tiering_active(&HookConfig::default(), "claude")); assert!(!per_edit_tiering_active(&HookConfig::default(), "cursor")); assert!(!per_edit_tiering_active(&HookConfig::default(), "github")); let mut all = HookConfig::default(); all.per_edit_rules = "all".into(); assert!(!per_edit_tiering_active(&all, "claude")); } // ── rendering ───────────────────────────────────────────────────────────── fn opts(cwd: &str) -> RenderOpts { RenderOpts { cwd: Some(cwd.to_string()), short_footer: false, reserve_chars: 0.0, } } #[test] fn render_template_caps_and_footers() { let r = rt("/x"); let c = HookConfig::default(); let many: Vec = (0..12) .map(|i| f("side-tab", (i + 1) as f64, &format!("R{i}"), "d", "s")) .collect(); let text = render_template(&r, &many, "/x/Card.tsx", &c, &opts("/x")); assert!(text.starts_with( "[impeccable@1] Design hook findings requiring review in Card.tsx (12 issue(s)):" )); assert!(text.contains("... and 7 more (see /impeccable audit).")); assert_eq!(text.lines().filter(|l| l.starts_with("- L")).count(), 5); // The self-command is quoted in the host's shell form (#476 / #533): // single quotes under sh, double quotes on Windows. let self_cmd = quote_command_arg("/opt/bin/impeccable", cfg!(windows)); assert!(text.contains(&format!( "Run `{self_cmd} hooks ignore-value \"\" --reason \"\"`" ))); assert!(text.contains("Full suppression ladder: /impeccable hooks.")); let short = render_template( &r, &many[..1], "/x/Card.tsx", &c, &RenderOpts { short_footer: true, ..opts("/x") }, ); assert!(short.contains("Triage per the session policy")); assert!(short.contains("`impeccable hooks ignore-value`")); assert!(!short.contains("Triage each finding")); let zero = render_template( &r, &[f("side-tab", 0.0, "X", "d", "s")], "/x/a.tsx", &c, &opts("/x"), ); assert!(zero.contains("\n- [side-tab] X. d\n")); } #[test] fn render_template_dedupes_descriptions_and_quotes_hints() { let r = rt("/x"); let c = HookConfig::default(); let desc = "Long registry description that should appear once."; let text = render_template( &r, &[ f( "overused-font", 2.0, "Overused font", desc, "font-family: \"Roboto\"", ), f( "overused-font", 9.0, "Overused font", desc, "font-family: \"Inter\"", ), ], "/x/fonts.css", &c, &opts("/x"), ); assert_eq!(text.matches(desc).count(), 1); assert!(text.contains( "- L9 [overused-font] Overused font. If intentional: `ignore-value overused-font Inter`." )); assert!(text.contains("`ignore-value overused-font Roboto`")); let bounce = render_template( &r, &[f( "bounce-easing", 1.0, "Bounce", "d", "animation: bounce-ball", )], "/x/main.css", &c, &opts("/x"), ); assert!(bounce.contains("If intentional: `ignore-value bounce-easing bounce-ball`.")); let hostile = render_template( &r, &[f( "overused-font", 1.0, "Overused font", "d", "body { font-family: \"$(touch pwned)\", sans-serif; }", )], "/x/fonts.css", &c, &opts("/x"), ); assert!(hostile.contains(&format!( "ignore-value overused-font {}", quote_command_arg("$(touch pwned)", cfg!(windows)) ))); let no_hint = { let mut x = f("side-tab", 1.0, "Side tab", "d", "s"); x.extras.insert("ignoreValue".into(), json!("Inter")); render_template(&r, &[x], "/x/a.tsx", &c, &opts("/x")) }; assert!(!no_hint.contains("ignore-value side-tab")); // platform quoting (#476 / #533) assert_eq!( quote_command_arg("Space Grotesk Var", false), "'Space Grotesk Var'" ); assert_eq!( quote_command_arg("Space Grotesk Var", true), "\"Space Grotesk Var\"" ); assert_eq!(quote_command_arg("it's", false), "'it'\\''s'"); assert_eq!(quote_command_arg("a\"b\\c", true), "\"a\\\"b\\\\c\""); assert_eq!(quote_command_arg("Inter", true), "Inter"); } #[test] fn render_template_clamps_inside_budget_and_keeps_policy() { let r = rt("/x"); let mut c = HookConfig::default(); c.limits.max_chars = 500.0; let long_path = format!("/x/{}Component.tsx", "deeply-nested/".repeat(6)); let six: Vec = (0..6) .map(|i| { f( "side-tab", (i + 1) as f64, "Side tab", "Colored side border.", "s", ) }) .collect(); let text = render_template( &r, &six, &long_path, &c, &RenderOpts { reserve_chars: 134.0, ..opts("/x") }, ); assert!(text.chars().count() <= 500, "{}", text.chars().count()); assert!(text.ends_with("unsure, ask in one line.")); let huge: Vec = (0..5) .map(|i| f("side-tab", (i + 1) as f64, "X", &"y".repeat(2000), "s")) .collect(); let text = render_template(&r, &huge, "/x/a.tsx", &c, &opts("/x")); assert!(text.chars().count() <= 500); let one = render_template(&r, &huge[..1], "/x/a.tsx", &c, &opts("/x")); assert!(one.chars().count() <= 500); assert!(one.contains("[side-tab]")); assert!(one.contains("Triage per the session policy")); let three: Vec = (1..=3) .map(|l| f("side-tab", l as f64, "X", "short issue", "s")) .collect(); let text = render_template(&r, &three, "/x/a.tsx", &c, &opts("/x")); assert!(text.chars().count() <= 500); for l in ["- L1 ", "- L2 ", "- L3 "] { assert!(text.contains(l), "{l} survives"); } assert!(text.contains("Triage per the session policy")); // grouped render: global cap of 5 across files, per-file "more" line let groups = vec![ Group { file_path: "/x/a.css".into(), findings: (1..=4) .map(|l| f("side-tab", l as f64, "X", "d", "s")) .collect(), }, Group { file_path: "/x/b.css".into(), findings: (1..=3) .map(|l| f("gradient-text", l as f64, "G", "d", "s")) .collect(), }, ]; let text = render_grouped_template(&r, &groups, &HookConfig::default(), &opts("/x")); assert!(text.starts_with("[impeccable@1] Design hook findings requiring review across 2 files (7 issue(s)):\na.css (4 issue(s)):\n")); assert!(text.contains("b.css (3 issue(s)):\n- L1 [gradient-text] G. d\n- ... 2 more in b.css (see /impeccable audit).")); let pending = render_pending_ack( &r, "/x/a.css", &["a:1".into(), "b:2".into(), "c:3".into(), "d:4".into()], "/x", ); assert!(pending .contains("Still has 4 finding(s) flagged earlier this session (a:1, b:2, c:3, +1 more).")); assert!(render_clean_ack(&r, "/x/a.css", "/x") .ends_with("keep following the project design system and the impeccable skill guidance.")); } // ── events / targets ────────────────────────────────────────────────────── #[test] fn harness_detection_and_github_normalization() { let r = rt("/p"); let gh: Map = json!({"sessionId": "g1", "toolName": "edit", "toolArgs": "{\"path\":\"src/a.tsx\",\"old_str\":\"a\"}"}) .as_object() .cloned() .unwrap(); assert_eq!(resolve_harness(&r, Some(&gh)), "github"); let ev = normalize_hook_event(&r, &gh, "/p", "github"); assert_eq!(ev["tool_input"]["file_path"], json!("src/a.tsx")); assert_eq!(ev["session_id"], json!("g1")); assert_eq!(ev["cwd"], json!("/p")); let patch: Map = json!({"sessionId": "g1", "toolName": "apply_patch", "toolArgs": "*** Begin Patch\n*** Add File: /abs/app.css\n+x\n*** End Patch"}) .as_object() .cloned() .unwrap(); let ev = normalize_hook_event(&r, &patch, "/p", "github"); assert_eq!(ev["tool_name"], json!("apply_patch")); assert_eq!(resolve_target_files(&r, &ev, "/p"), vec!["/abs/app.css"]); // an edit whose content carries patch markers is still an edit let tricky: Map = json!({"toolName": "edit", "toolArgs": "{\"path\":\"/p/x.css\",\"new_str\":\"*** Begin Patch\"}"}) .as_object() .cloned() .unwrap(); let ev = normalize_hook_event(&r, &tricky, "/p", "github"); assert_eq!(ev["tool_input"]["file_path"], json!("/p/x.css")); let cursor: Map = json!({"conversation_id": "c", "workspace_roots": ["/w"], "tool_input": {"path": "src/App.jsx"}}) .as_object() .cloned() .unwrap(); assert_eq!(resolve_harness(&r, Some(&cursor)), "cursor"); let ev = normalize_hook_event(&r, &cursor, "/p", "cursor"); assert_eq!(ev["cwd"], json!("/w")); assert_eq!(ev["session_id"], json!("c")); assert_eq!(ev["tool_input"]["file_path"], json!("src/App.jsx")); // c9e7cd8a: an explicit codex harness now keeps its own identity so the // Stop pass can emit the Codex decision/block contract. let forced = rt_with("/p", env(&[("IMPECCABLE_HOOK_HARNESS", "codex")])); assert_eq!(resolve_harness(&forced, Some(&gh)), "codex"); assert_eq!( parse_apply_patch_paths(&r, "*** Begin Patch\n*** Update File: a.css\r\n*** Add File: /abs/b.css\n*** Delete File: c.css\n", "/p"), // A relative patch path is resolved against the cwd with the host's // path semantics; an already-absolute one is passed through. vec![jsp::join(&["/p", "a.css"]), "/abs/b.css".to_string()] ); assert_eq!( payload("t", "Stop", "claude"), r#"{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"t"}}"# ); assert_eq!( payload("t", "PostToolUse", "cursor"), r#"{"additional_context":"t"}"# ); assert_eq!( payload("t", "PostToolUse", "github"), r#"{"additionalContext":"t"}"# ); } #[test] fn expand_scan_targets_follows_styles() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let app = t.write( "src/App.jsx", "import './theme.scss';\nimport styles from \"./App.module.less\";\nexport default 1;\n", ); t.write("src/styles.css", "a{}"); t.write("src/index.sass", "a{}"); t.write("src/theme.scss", "a{}"); t.write("src/App.module.less", "a{}"); let out = expand_scan_targets(&r, &[app.clone()], &cwd); assert_eq!(out[0], app); for name in [ "src/theme.scss", "src/App.module.less", "src/styles.css", "src/index.sass", ] { assert!(out.contains(&jsp::join(&[&cwd, name])), "{name} in {out:?}"); } let rel = expand_scan_targets(&r, &["src/App.jsx".into()], &cwd); assert_eq!( rel[0], app, "relative primaries resolve against the project cwd" ); let trav = expand_scan_targets(&r, &[format!("{cwd}/src/../src/App.jsx")], &cwd); assert_eq!( trav.len(), 1, "traversal-looking primaries are not expanded" ); let css = t.write("src/main.css", "a{}"); assert_eq!(expand_scan_targets(&r, &[css.clone()], &cwd), vec![css]); assert!(expand_scan_targets(&r, &[], &cwd).is_empty()); let capped: Vec = (0..9).map(|i| format!("{cwd}/f{i}.css")).collect(); assert_eq!( normalize_scan_targets(&r, &capped, &cwd).len(), MAX_SCAN_TARGETS ); } // ── run_hook ────────────────────────────────────────────────────────────── #[test] fn run_hook_fresh_then_pending_then_stop() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let css = t.write("src/a.css", &format!("{GRADIENT_CSS}{SIDE_TAB_CSS}")); let ev = edit_event(&cwd, &css, "s1"); let one = hook::run_hook(&r, &ev); assert!(one .stdout .contains("Design hook findings requiring review in src/a.css (1 issue(s))")); assert!( one.stdout.contains("[gradient-text]"), "immediate tier only" ); assert!(!one.stdout.contains("[side-tab]")); assert_eq!(one.audit["deferred"], json!(1)); assert_eq!(one.audit["freshFindings"], json!(1)); assert!(t.exists(".impeccable/hook.cache.json")); let two = hook::run_hook(&r, &ev); assert!(two .stdout .contains("Still has 1 finding(s) flagged earlier this session (gradient-text:1)")); assert_eq!(audit_str(&two.audit, "kind"), Some("pending")); assert!(two .stdout .contains("Handle them before finalizing — the previous reminder still applies.")); let stop = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(stop.stdout.contains("\"hookEventName\":\"Stop\"")); assert!( stop.stdout.contains("[side-tab]"), "deep pass surfaces the deferred tier" ); assert!( !stop.stdout.contains("[gradient-text]"), "already reported per edit" ); assert!( stop.stdout.contains("Triage per the session policy"), "short footer after the first fire" ); // 3c442af7: the Stop pass now syncs the remembered set to the live scan // (including the per-edit-surfaced gradient-text), so a second Stop with // nothing new is silent instead of re-reporting it. let again = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(again.stdout, "", "second Stop is clean: {}", again.stdout); assert_eq!(audit_str(&again.audit, "skipped"), Some("stop-clean")); // a session whose deep pass found everything is silent on the next Stop let css2 = t.write("src/b.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_event(&cwd, &css2, "s2")); assert!(hook::run_stop_hook(&r, &stop_event(&cwd, "s2")) .stdout .contains("[side-tab]")); assert_eq!( audit_str( &hook::run_stop_hook(&r, &stop_event(&cwd, "s2")).audit, "skipped" ), Some("stop-clean") ); let active = hook::run_stop_hook(&r, &json!({"session_id": "s1", "cwd": cwd, "hook_event_name": "Stop", "stop_hook_active": true}).to_string()); assert_eq!( audit_str(&active.audit, "skipped"), Some("stop-hook-active") ); let none = hook::run_stop_hook(&r, &stop_event(&cwd, "nobody")); assert_eq!(audit_str(&none.audit, "skipped"), Some("no-touched-files")); } #[test] fn run_hook_acks_and_quiet_modes() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let r = rt(&cwd); let clean = t.write("src/Clean.tsx", "export const A = () =>

hi

;\n"); let ev = edit_event(&cwd, &clean, "s1"); let one = hook::run_hook(&r, &ev); assert!(one .stdout .contains("No deterministic design-quality issues found")); assert!( !t.exists(".impeccable"), "clean edit in a project without a footprint writes nothing" ); std::fs::create_dir_all(t.0.join(".impeccable")).unwrap(); let one = hook::run_hook(&r, &ev); assert_eq!(audit_str(&one.audit, "kind"), Some("clean")); let two = hook::run_hook(&r, &ev); assert_eq!(audit_str(&two.audit, "skipped"), Some("clean-ack-deduped")); assert!(two.stdout.is_empty()); let ts = t.write("src/util.ts", "export const x = 1;\n"); let three = hook::run_hook(&r, &edit_event(&cwd, &ts, "s1")); assert_eq!(audit_str(&three.audit, "skipped"), Some("non-ui-ack")); let ts_slop = t.write("src/slop.ts", "const s = `.t{background: linear-gradient(90deg,#f00,#00f); -webkit-background-clip: text; color: transparent;}`;\n"); let four = hook::run_hook(&r, &edit_event(&cwd, &ts_slop, "s1")); assert!( four.stdout.contains("[gradient-text]"), "findings still surface for .ts" ); let quiet = rt_with(&cwd, env(&[("IMPECCABLE_HOOK_QUIET", "1")])); let q = hook::run_hook(&quiet, &edit_event(&cwd, &clean, "s2")); assert_eq!(q.audit["quiet"], json!(true)); assert!(q.stdout.is_empty()); let re = rt_with(&cwd, env(&[("CLAUDE_HOOK_DEPTH", "2")])); assert_eq!(hook::run_hook(&re, &ev).audit["reentrant"], json!(true)); let off = rt_with(&cwd, env(&[("IMPECCABLE_HOOK_DISABLED", "yes")])); assert_eq!( audit_str(&hook::run_hook(&off, &ev).audit, "skipped"), Some("env-disabled") ); assert_eq!( audit_str(&hook::run_hook(&r, "").audit, "skipped"), Some("stdin-malformed") ); assert_eq!( audit_str(&hook::run_hook(&r, "[1]").audit, "skipped"), Some("stdin-empty") ); } #[test] fn run_hook_skips_unsafe_and_foreign_targets() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let r = rt(&cwd); let go = |file: &str| hook::run_hook(&r, &edit_event(&cwd, file, "s1")); assert_eq!( audit_str(&go(&format!("{cwd}/.env.local")).audit, "skipped"), Some("sensitive") ); assert_eq!( audit_str(&go(&format!("{cwd}/dist/a.css")).audit, "skipped"), Some("generated") ); assert_eq!( audit_str(&go(&format!("{cwd}/src/../a.css")).audit, "skipped"), Some("sensitive") ); assert_eq!( audit_str(&go(&format!("{cwd}/README.md")).audit, "skipped"), Some("extension") ); assert_eq!( audit_str(&go(&format!("{cwd}/src/nope.tsx")).audit, "skipped"), Some("file-missing") ); let scratch = Tmp::new(); let outside = scratch.write("landing.css", GRADIENT_CSS); assert_eq!( audit_str(&go(&outside).audit, "skipped"), Some("outside-project") ); assert!(!t.exists(".impeccable")); // template extensions (#316): .blade.php is skipped without config, // routed through the text engine with `engine: text` let blade = t.write("views/a.blade.php", ""); assert_eq!(audit_str(&go(&blade).audit, "skipped"), Some("extension")); t.write( ".impeccable/config.json", r#"{"detector":{"extensions":[{"ext":".blade.php","engine":"text"}]}}"#, ); let res = go(&blade); assert!(res.stdout.contains("[gradient-text]"), "{}", res.stdout); assert_eq!(audit_str(&res.audit, "ext"), Some(".blade.php")); } #[cfg(unix)] #[test] fn run_hook_symlinked_cwd_and_umbrella_launch() { let t = Tmp::new(); let root = t.path(); let r = rt(&root); t.write("realproj/package.json", "{}"); let file = t.write( "realproj/src/Card.tsx", "export const A = () =>

hi

;\n", ); let link = format!("{root}/proj-link"); std::os::unix::fs::symlink(format!("{root}/realproj"), &link).unwrap(); let res = hook::run_hook(&r, &edit_event(&link, &file, "sym")); assert_ne!(audit_str(&res.audit, "skipped"), Some("outside-project")); assert!(res .stdout .contains("No deterministic design-quality issues found")); // umbrella: cwd has no marker; the child project gets the cache t.write("app/package.json", "{}"); let css = t.write("app/src/a.css", GRADIENT_CSS); let res = hook::run_hook(&r, &edit_event(&root, &css, "u1")); assert!(res.stdout.contains("Design hook findings requiring review")); assert_eq!( audit_str(&res.audit, "cwd"), Some(format!("{root}/app").as_str()) ); assert!(t.exists("app/.impeccable/hook.cache.json")); assert!(!t.exists(".impeccable")); } #[test] fn run_hook_oversized_files_and_suppression() { let t = Tmp::new(); let cwd = t.path(); std::fs::create_dir_all(t.0.join(".impeccable")).unwrap(); let r = rt(&cwd); let big = t.write("bundle.js", &format!("/* {} */", "x".repeat(200 * 1024))); let res = hook::run_hook(&r, &edit_event(&cwd, &big, "s1")); assert_eq!(audit_str(&res.audit, "skipped"), Some("too-large")); assert_eq!(res.audit["bytes"], json!(200 * 1024 + 6)); let main = t.write( "main.css", &format!("/* {} */\n{GRADIENT_CSS}", "x".repeat(90 * 1024)), ); let res = hook::run_hook(&r, &edit_event(&cwd, &main, "s1")); assert_eq!(res.audit["emitted"], json!(true)); // the byte count never rides along on another file's audit line let small = t.write("a.css", "a{}"); let patch = json!({"session_id": "p", "cwd": cwd, "hook_event_name": "PostToolUse", "tool_name": "apply_patch", "tool_input": {"command": format!("*** Begin Patch\n*** Update File: {big}\n*** Update File: {small}\n*** End Patch")}}) .to_string(); let res = hook::run_hook(&r, &patch); assert!(res.audit.get("bytes").is_none(), "{:?}", res.audit); t.write( ".impeccable/config.json", r#"{"hook":{"limits":{"maxFileBytes":1024}}}"#, ); let res = hook::run_hook(&r, &edit_event(&cwd, &main, "s3")); assert_eq!( audit_str(&res.audit, "skipped"), Some("too-large"), "configured maxFileBytes is honored" ); // suppression: the 7th edit emits the notice once, later edits stay silent t.write(".impeccable/config.json", "{}"); let css = t.write("src/b.css", GRADIENT_CSS); let mut outputs = Vec::new(); for _ in 0..9 { outputs.push(hook::run_hook(&r, &edit_event(&cwd, &css, "sup"))); } assert!(outputs[6].stdout.contains("Suppressing further design hints on src/b.css. More than 6 edits in this session reached. Run /impeccable audit to revisit.")); assert_eq!(outputs[6].audit["suppressed"], json!(true)); assert!(outputs[7].stdout.is_empty()); assert_eq!(outputs[8].audit["emitted"], json!(false)); assert_eq!(outputs[8].audit["editCount"], json!(9)); } #[test] fn run_hook_co_located_styles_and_tiering_config() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let r = rt(&cwd); let app = t.write( "src/App.jsx", "export default () =>
;\n", ); t.write("src/styles.css", &format!("{SIDE_TAB_CSS}{GRADIENT_CSS}")); let res = hook::run_hook(&r, &edit_event(&cwd, &app, "s1")); assert!( res.stdout .contains("Design hook findings requiring review in src/styles.css (1 issue(s))"), "{}", res.stdout ); let cache = read_cache(&cwd); assert_eq!( cache["sessions"]["s1"]["files"][&app]["editCount"], json!(1) ); assert_eq!( cache["sessions"]["s1"]["files"][jsp::join(&[&cwd, "src/styles.css"])]["editCount"], json!(0), "co-scanned styles do not bump" ); // perEditRules: all restores the deferred tier per edit t.write( ".impeccable/config.json", r#"{"hook":{"perEditRules":"all"}}"#, ); let res = hook::run_hook(&r, &edit_event(&cwd, &app, "s2")); assert!(res.stdout.contains("[side-tab]"), "{}", res.stdout); assert!(res.stdout.contains("(2 issue(s))")); // github harness keeps the full set too t.write(".impeccable/config.json", "{}"); let gh = rt_with(&cwd, env(&[("IMPECCABLE_HOOK_HARNESS", "github")])); let res = hook::run_hook(&gh, &edit_event(&cwd, &app, "s3")); assert!(res.stdout.starts_with("{\"additionalContext\":")); assert!(res.stdout.contains("[side-tab]")); // ignoreFiles glob and ignoreRules t.write( ".impeccable/config.json", r#"{"detector":{"ignoreFiles":["src/**"]}}"#, ); let res = hook::run_hook(&r, &edit_event(&cwd, &app, "s4")); assert_eq!(audit_str(&res.audit, "skipped"), Some("config-ignore-file")); t.write( ".impeccable/config.json", r#"{"detector":{"ignoreRules":["gradient-text"]}}"#, ); let res = hook::run_hook(&r, &edit_event(&cwd, &app, "s5")); assert!( res.stdout .contains("No deterministic design-quality issues found"), "{}", res.stdout ); // native platform gate t.write("PRODUCT.md", "# P\n\n## Platform\nios and android\n"); let res = hook::run_hook(&r, &edit_event(&cwd, &app, "s6")); assert_eq!(audit_str(&res.audit, "skipped"), Some("native-platform")); assert_eq!(audit_str(&res.audit, "platform"), Some("adaptive")); } #[test] fn write_audit_log_targets() { let t = Tmp::new(); let cwd = t.path(); let mut entry = Map::new(); entry.insert("event".into(), json!("PostToolUse")); entry.insert("cwd".into(), json!(cwd)); let r = rt_with( "/elsewhere", env(&[("IMPECCABLE_HOOK_LOG", "logs/a.ndjson")]), ); assert!(write_audit_log(&r, &entry, "/elsewhere")); let line = t.read("logs/a.ndjson"); assert!( line.starts_with("{\"ts\":\"") && line.contains("\",\"event\":\"PostToolUse\",\"cwd\":\"") && line.ends_with("\"}\n") ); let r2 = rt("/elsewhere"); assert!( !write_audit_log(&r2, &entry, "/elsewhere"), "no target, no-op" ); t.write( ".impeccable/config.json", r#"{"hook":{"auditLog":"~/h.ndjson"}}"#, ); let r3 = rt_with("/elsewhere", env(&[("HOME", &cwd)])); assert!(write_audit_log(&r3, &entry, "/elsewhere")); assert!(t.exists("h.ndjson")); } // ── hook-before-edit ────────────────────────────────────────────────────── fn hbe(r: &Runtime, stdin: &str) -> (String, i32) { let (mut io, cap) = Io::captured("", PathBuf::from(&r.proc_cwd), r.env.clone()); let code = before_edit::run(r, stdin, &mut io); drop(io); let out = String::from_utf8(cap.stdout.borrow().clone()).unwrap(); drop(cap); (out, code) } fn cursor(cwd: &str, tool: &str, input: Value) -> String { json!({"hook_event_name": "preToolUse", "conversation_id": "cv1", "workspace_roots": [cwd], "tool_name": tool, "tool_input": input}).to_string() } #[test] fn before_edit_skips_oversized_proposed_content() { // Over-cap proposed content (a huge paste or hostile envelope arriving // via stdin) must skip the gate instead of being scanned, the same // fail-open shape as an unreadable original (triage A3). The envelope // carries slop that denies at normal size, so an allow proves the cap // fired rather than the scan passing. let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let r = rt(&cwd); let slop = ".t { background: linear-gradient(90deg,#f00,#00f); -webkit-background-clip: text; color: transparent; }\n"; let (small_out, _) = hbe( &r, &cursor(&cwd, "Write", json!({"file_path": "src/x.css", "content": slop})), ); assert!(small_out.starts_with("{\"permission\":\"deny\""), "{small_out}"); let big = format!("{slop}/*{}*/\n", "a".repeat(2 * 1024 * 1024)); let (out, code) = hbe( &r, &cursor(&cwd, "Write", json!({"file_path": "src/x.css", "content": big})), ); assert_eq!(code, 0); assert_eq!(out, "{\"permission\":\"allow\"}"); } #[test] fn before_edit_denies_shell_and_edit_shapes() { let t = Tmp::new(); let cwd = t.path(); t.write("package.json", "{}"); let r = rt(&cwd); let slop = ".t { background: linear-gradient(90deg,#f00,#00f); -webkit-background-clip: text; color: transparent; }\n"; let deny = |stdin: &str, label: &str| { let (out, code) = hbe(&r, stdin); assert_eq!(code, 0); assert!(out.starts_with("{\"permission\":\"deny\",\"user_message\":\"[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review in"), "{label}: {out}"); out }; let allow = |stdin: &str, label: &str| { let (out, _) = hbe(&r, stdin); assert_eq!(out, "{\"permission\":\"allow\"}", "{label}"); }; deny( &cursor( &cwd, "Shell", json!({"command": format!("cat > src/x.css <<'EOF'\n{slop}EOF\n")}), ), "heredoc", ); deny( &cursor( &cwd, "Shell", json!({"command": format!("python3 - <<'PY'\nfrom pathlib import Path\nPath(\"src/y.css\").write_text(\"\"\"{slop}\"\"\")\nPY\n")}), ), "python heredoc", ); deny( &cursor( &cwd, "Shell", json!({"command": format!("cat >> src/z.css < src/q.css"})), "redirect without content", ); allow( &cursor(&cwd, "Write", json!({"path": "src/new.css", "content": ""})), "empty content", ); allow( &cursor( &cwd, "Write", json!({"path": "src/data.json", "content": "{}"}), ), "non-ui", ); allow(&cursor(&cwd, "Shell", json!({"command": "ls"})), "no file"); allow("", "empty stdin"); allow("{", "malformed stdin"); let off = rt_with(&cwd, env(&[("IMPECCABLE_HOOK_DISABLED", "true")])); let (out, _) = hbe(&off, "{"); assert_eq!(out, "{\"permission\":\"allow\"}"); // repeated identical denials downgrade to allow-with-warning after the threshold let write = cursor( &cwd, "Write", json!({"path": "src/new.css", "content": slop}), ); let mut last = String::new(); for _ in 0..7 { last = hbe(&r, &write).0; } assert!( last.starts_with("{\"permission\":\"allow\",\"user_message\":\""), "{last}" ); assert!(last.contains("This is the 7th repeated denial for the same file and finding signature, so Impeccable is allowing this write to avoid a loop.")); let cache = read_cache(&cwd); assert_eq!( cache["sessions"]["cv1"]["files"][jsp::join(&[&cwd, "src/new.css"])]["cursorDenials"] ["gradient-text:1"], json!(7) ); assert_eq!(cache["sessions"]["cv1"]["footerShown"], json!(true)); } // ── hook-admin ──────────────────────────────────────────────────────────── fn admin_run(r: &Runtime, args: &[&str]) -> (String, String, i32) { let (mut io, cap) = Io::captured("", PathBuf::from(&r.proc_cwd), r.env.clone()); let argv: Vec = args.iter().map(|s| s.to_string()).collect(); let code = admin::run(r, &argv, &mut io); drop(io); let out = String::from_utf8(cap.stdout.borrow().clone()).unwrap(); let err = String::from_utf8(cap.stderr.borrow().clone()).unwrap(); drop(cap); (out, err, code) } #[test] fn admin_ignore_value_scoping_and_idempotency() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let (out, _, code) = admin_run(&r, &["ignore-value", "overused-font", "Inter"]); assert_eq!(code, 0); assert_eq!( out, format!("Added overused-font=inter to shared detector.ignoreValues ({}).\n", shared_config_rel()) ); assert!(!t.exists(".impeccable/config.local.json")); let cfg: Value = serde_json::from_str(&t.read(".impeccable/config.json")).unwrap(); let entry = &cfg["detector"]["ignoreValues"][0]; assert_eq!( entry .as_object() .unwrap() .keys() .cloned() .collect::>(), vec!["rule", "value", "createdAt"] ); let before = t.read(".impeccable/config.json"); // an unrelated edit keeps the entry byte-identical admin_run(&r, &["ignore-rule", "side-tab"]); let after = t.read(".impeccable/config.json"); assert!(after.contains( &before[before.find("\"ignoreValues\"").unwrap()..before.find("\n }").unwrap()] )); let (out, _, _) = admin_run( &r, &[ "ignore-value", "design-system-font-size", "*", "--file", "src/z.js", "--files=src/a.js", "--file", "src/a.js", "--local", ], ); assert_eq!(out, format!("Added design-system-font-size=* scoped to src/a.js, src/z.js to local detector.ignoreValues ({}).\n", local_config_rel())); let (out, _, _) = admin_run(&r, &["status"]); assert!(out.contains( "ignoreValues: overused-font=inter, design-system-font-size=* [src/a.js, src/z.js]\n" )); assert!(out.contains("ignoreRules: side-tab\n")); let (_, err, code) = admin_run(&r, &["ignore-value", "overused-font", "*"]); assert_eq!(code, 1); assert_eq!(err, "Error: Wildcard value ignores must be scoped with --file , e.g. /impeccable hooks ignore-value design-system-font-size \"*\" --file \"src/widget.js\". To suppress the rule project-wide use /impeccable hooks ignore-rule overused-font --all-values.\n"); let (_, err, _) = admin_run(&r, &["ignore-value", "overused-font", "Inter", "--file="]); assert_eq!(err, "Error: --file requires a non-empty glob\n"); let (_, err, _) = admin_run(&r, &["ignore-value", "overused-font", "Inter", "--shard"]); assert_eq!(err, "Error: Unknown ignore-value flag: --shard\n"); let (_, err, code) = admin_run(&r, &["bogus"]); assert_eq!(code, 1); assert_eq!(err, "Unknown action: bogus\nValid: status, on, off, ignore-rule, ignore-file, ignore-value, reset\n"); let (out, _, _) = admin_run(&r, &["reset"]); assert_eq!(out, format!("Reset design hook config and cache (removed: {}, {}).\n", shared_config_rel(), local_config_rel())); assert!(!t.exists(".impeccable/config.json")); } /// Upstream be87f5eb (#662), the `hooks ignore-value` twin of the detect-side /// port (engine 09f8ae7): exact values for rules whose findings can never /// extract a value are refused with the wildcard-plus-file route; wildcard /// scoped entries and extractable rules stay accepted. #[test] fn admin_ignore_value_refuses_inert_exact_values() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let (_, err, code) = admin_run(&r, &["ignore-value", "cramped-padding", "padding: 4px 8px"]); assert_eq!(code, 1); assert_eq!(err, "Error: cramped-padding has no extractable ignore value. Use /impeccable hooks ignore-value cramped-padding \"*\" --file to suppress it in matching files.\n"); let (_, err, code) = admin_run(&r, &["ignore-value", "side-tab", "Inter", "--file", "a.css"]); assert_eq!(code, 1); assert_eq!(err, "Error: side-tab has no extractable ignore value. Use /impeccable hooks ignore-value side-tab \"*\" --file to suppress it in matching files.\n"); assert!(!t.exists(".impeccable/config.json"), "a refused ignore must not write config"); let (out, _, code) = admin_run(&r, &["ignore-value", "overused-font", "Inter"]); assert_eq!(code, 0); assert!(out.contains("Added overused-font=inter"), "{out}"); let (_, _, code) = admin_run(&r, &["ignore-value", "cramped-padding", "*", "--file", "index.html"]); assert_eq!(code, 0); let cfg: Value = serde_json::from_str(&t.read(".impeccable/config.json")).unwrap(); let entries: Vec<&Value> = cfg["detector"]["ignoreValues"] .as_array() .unwrap() .iter() .filter(|e| e["rule"] == json!("cramped-padding")) .collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0]["value"], json!("*")); assert_eq!(entries[0]["files"], json!(["index.html"])); } #[test] fn admin_on_off_preserve_sibling_hook_fields() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); t.write(".impeccable/config.json", "{\n \"hook\": {\n \"quiet\": true,\n \"consent\": \"accepted\",\n \"advisoryRules\": \"include\"\n },\n \"updateCheck\": false\n}\n"); let (out, _, _) = admin_run(&r, &["off"]); assert_eq!( out, format!("Design hook disabled for this project (wrote {}).\n", shared_config_rel()) ); let cfg: Value = serde_json::from_str(&t.read(".impeccable/config.json")).unwrap(); assert_eq!(cfg["hook"]["quiet"], json!(true)); assert_eq!(cfg["hook"]["consent"], json!("accepted")); assert_eq!(cfg["hook"]["enabled"], json!(false)); assert_eq!( cfg["hook"]["limits"], json!({"maxFindings": 5, "maxChars": 8000}) ); assert_eq!( cfg["detector"]["advisoryRules"], json!("include"), "legacy key migrates to detector" ); assert!(cfg["hook"].get("advisoryRules").is_none()); assert_eq!(cfg["updateCheck"], json!(false)); assert_eq!( cfg.as_object().unwrap().keys().cloned().collect::>(), vec!["hook", "updateCheck", "detector"] ); std::fs::create_dir_all(t.0.join(".github/skills/impeccable")).unwrap(); let (out, _, _) = admin_run(&r, &["on"]); assert_eq!(out, format!( "Design hook enabled for this project (wrote {}). Recorded local hook consent in {}. Installed or repaired hook manifests for: .github.\n", shared_config_rel(), local_config_rel() )); assert!(t .read(".github/hooks/impeccable.json") .contains("\"matcher\": \"edit|create|apply_patch\"")); let (out, _, _) = admin_run(&r, &["on"]); assert!(out.ends_with("Hook manifests already installed for: .github.\n")); let (out, _, _) = admin_run(&r, &["status"]); assert!(out.contains("state: enabled\n")); assert!(out.contains(&format!("local file: {}\n", local_config_rel()))); } #[test] fn admin_on_prunes_local_manifest_when_shared_settings_carry_the_hook() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); std::fs::create_dir_all(t.0.join(".claude/skills/impeccable")).unwrap(); t.write( ".claude/settings.json", r#"{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\""}]}]}}"#, ); t.write( ".claude/settings.local.json", "{\n \"permissions\": {\n \"allow\": [\n \"Bash(ls)\"\n ]\n },\n \"hooks\": {\n \"PostToolUse\": [\n {\n \"matcher\": \"Edit\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"node old/skills/impeccable/scripts/hook.mjs\"\n }\n ]\n }\n ]\n }\n}\n", ); let (out, _, _) = admin_run(&r, &["on"]); assert!( out.ends_with("Hook manifests already installed for: .claude.\n"), "{out}" ); let local: Value = serde_json::from_str(&t.read(".claude/settings.local.json")).unwrap(); assert!( local.get("hooks").is_none(), "impeccable entries pruned, empty hooks dropped: {local}" ); assert_eq!(local["permissions"]["allow"], json!(["Bash(ls)"])); // a local manifest holding only impeccable entries is deleted outright t.write( ".claude/settings.local.json", r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"node x/skills/impeccable/scripts/hook.mjs"}]}]}}"#, ); admin_run(&r, &["on"]); assert!(!t.exists(".claude/settings.local.json")); } #[test] fn admin_on_writes_launcher_manifests_for_every_harness() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); for skill in [ ".claude/skills/impeccable", ".agents/skills/impeccable", ".cursor/skills/impeccable", ".github/skills/impeccable", ] { std::fs::create_dir_all(t.0.join(skill)).unwrap(); } let (out, _, code) = admin_run(&r, &["on"]); assert_eq!(code, 0, "{out}"); assert!(out.ends_with("Installed or repaired hook manifests for: .claude, .agents, .cursor, .github.\n"), "{out}"); let claude: Value = serde_json::from_str(&t.read(".claude/settings.local.json")).unwrap(); let cmd = "\"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/impeccable\" hook"; assert_eq!(claude["hooks"]["PostToolUse"][0]["hooks"][0]["command"], json!(cmd)); assert_eq!(claude["hooks"]["Stop"][0]["hooks"][0]["command"], json!(cmd)); assert!(claude["hooks"]["PostToolUse"][0]["hooks"][0].get("commandWindows").is_none()); assert!(!t.read(".claude/settings.local.json").contains("node ")); let codex: Value = serde_json::from_str(&t.read(".codex/hooks.json")).unwrap(); let entry = &codex["hooks"]["PostToolUse"][0]["hooks"][0]; assert_eq!(entry["command"], json!("\".agents/skills/impeccable/scripts/impeccable\" hook")); assert_eq!(entry["commandWindows"], json!("\".agents/skills/impeccable/scripts/impeccable.cmd\" hook")); assert_eq!( entry.as_object().unwrap().keys().cloned().collect::>(), vec!["type", "command", "commandWindows", "timeout", "statusMessage"] ); let stop = &codex["hooks"]["Stop"][0]["hooks"][0]; assert_eq!(stop["command"], json!("\".agents/skills/impeccable/scripts/impeccable\" hook")); assert_eq!(stop["commandWindows"], json!("\".agents/skills/impeccable/scripts/impeccable.cmd\" hook")); assert_eq!(stop["timeout"], json!(30)); let cursor: Value = serde_json::from_str(&t.read(".cursor/hooks.json")).unwrap(); assert_eq!( cursor["hooks"]["preToolUse"][0]["command"], json!("\".cursor/skills/impeccable/scripts/impeccable\" hook-before-edit") ); let github: Value = serde_json::from_str(&t.read(".github/hooks/impeccable.json")).unwrap(); assert_eq!( github["hooks"]["postToolUse"][0]["bash"], json!("\"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" hook") ); // A second `on` is a no-op against the manifests it just wrote. let (out, _, _) = admin_run(&r, &["on"]); assert!(out.ends_with("Hook manifests already installed for: .claude, .agents, .cursor, .github.\n"), "{out}"); } #[test] fn admin_on_repairs_legacy_mjs_manifests_to_the_launcher_form() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); std::fs::create_dir_all(t.0.join(".claude/skills/impeccable")).unwrap(); std::fs::create_dir_all(t.0.join(".agents/skills/impeccable")).unwrap(); // JS-era Claude manifest with a foreign entry alongside the impeccable one. t.write( ".claude/settings.local.json", r#"{"permissions":{"allow":["Bash(ls)"]},"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"echo other"}]},{"matcher":"Edit|Write|MultiEdit","hooks":[{"type":"command","command":"node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"","timeout":5}]}],"Stop":[{"hooks":[{"type":"command","command":"[ ! -f '/x/.claude/skills/impeccable/scripts/hook.mjs' ] || node '/x/.claude/skills/impeccable/scripts/hook.mjs'"}]}]}}"#, ); // JS-era Codex manifest carrying the CLI installer's commandWindows sibling. t.write( ".codex/hooks.json", r#"{"hooks":{"PostToolUse":[{"matcher":"Edit|Write|apply_patch","hooks":[{"type":"command","command":"[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"","commandWindows":"if exist \".agents/skills/impeccable/scripts/hook.mjs\" (node \".agents/skills/impeccable/scripts/hook.mjs\" & exit /b)","timeout":5}]}]}}"#, ); let (out, _, code) = admin_run(&r, &["on"]); assert_eq!(code, 0, "{out}"); assert!(out.ends_with("Installed or repaired hook manifests for: .claude, .agents.\n"), "{out}"); let claude = t.read(".claude/settings.local.json"); assert!(!claude.contains(".mjs"), "legacy entries replaced: {claude}"); let claude: Value = serde_json::from_str(&claude).unwrap(); assert_eq!(claude["permissions"]["allow"], json!(["Bash(ls)"])); let post = claude["hooks"]["PostToolUse"].as_array().unwrap(); assert_eq!(post.len(), 2, "foreign entry kept, impeccable entry replaced once: {post:?}"); assert_eq!(post[0]["hooks"][0]["command"], json!("echo other")); assert_eq!( post[1]["hooks"][0]["command"], json!("\"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/impeccable\" hook") ); // Upstream 611147a3: the repaired Claude group must exist and carry the // current Edit|Write matcher (MultiEdit is retired; see 55fb8e8). assert_eq!(post[1]["matcher"], json!("Edit|Write")); assert_eq!(claude["hooks"]["Stop"].as_array().unwrap().len(), 1); let codex = t.read(".codex/hooks.json"); assert!(!codex.contains(".mjs"), "{codex}"); let codex: Value = serde_json::from_str(&codex).unwrap(); assert_eq!(codex["hooks"]["PostToolUse"].as_array().unwrap().len(), 1); assert_eq!( codex["hooks"]["PostToolUse"][0]["hooks"][0]["commandWindows"], json!("\".agents/skills/impeccable/scripts/impeccable.cmd\" hook") ); // A launcher-form manifest written by another checkout is recognized as // ours too: shared settings carrying it make `on` prune the local file. t.write( ".claude/settings.json", r#"{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"\"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/impeccable\" hook"}]}]}}"#, ); let (out, _, _) = admin_run(&r, &["on"]); assert!(out.contains("already installed for: .claude"), "{out}"); let local = t.read(".claude/settings.local.json"); assert!(!local.contains("skills/impeccable"), "launcher-form entries pruned: {local}"); let local: Value = serde_json::from_str(&local).unwrap(); assert_eq!(local["hooks"]["PostToolUse"][0]["hooks"][0]["command"], json!("echo other")); assert!(local["hooks"].get("Stop").is_none()); assert_eq!(local["permissions"]["allow"], json!(["Bash(ls)"])); } // ── Grok Build + Codex (#646, #603, upstream 35ae0733/bfe634e2/3c442af7/c9e7cd8a) ── const MIXED_CSS: &str = ".title { background: linear-gradient(90deg, #f472b6, #a78bfa); -webkit-background-clip: text; color: transparent; }\n.card { border-left: 4px solid #6366f1; border-radius: 8px; }\n"; #[test] fn grok_and_codex_harness_detection() { let r = rt("/p"); // Grok Build camelCase envelope wins over the old GitHub heuristic. let grok = json!({ "hookEventName": "post_tool_use", "sessionId": "g1", "cwd": "/w", "toolName": "str_replace", "toolInput": { "file_path": "src/a.css" }, }) .as_object() .cloned() .unwrap(); assert_eq!(resolve_harness(&r, Some(&grok)), "grok"); // GitHub Copilot still classifies by toolArgs. let gh = json!({ "toolName": "str_replace_editor", "toolArgs": "{}" }).as_object().cloned().unwrap(); assert_eq!(resolve_harness(&r, Some(&gh)), "github"); // A Stop envelope with only hookEventName is Grok too. let grok_stop = json!({ "hookEventName": "stop", "sessionId": "g1", "cwd": "/w" }).as_object().cloned().unwrap(); assert_eq!(resolve_harness(&r, Some(&grok_stop)), "grok"); // Codex turn-scoped events carry turn_id; Claude Code does not. let codex = json!({ "hook_event_name": "Stop", "session_id": "s", "turn_id": "t-1" }).as_object().cloned().unwrap(); assert_eq!(resolve_harness(&r, Some(&codex)), "codex"); let claude = json!({ "hook_event_name": "Stop", "session_id": "s" }).as_object().cloned().unwrap(); assert_eq!(resolve_harness(&r, Some(&claude)), "claude"); // Explicit env overrides. let forced = rt_with("/p", env(&[("IMPECCABLE_HOOK_HARNESS", "grok")])); assert_eq!(resolve_harness(&forced, Some(&gh)), "grok"); // Stop detection matches both casings, on the raw stdin shape. assert!(is_stop_event(&grok_stop)); assert!(is_stop_event(&claude)); assert!(!is_stop_event(&grok)); } #[test] fn grok_post_tool_use_scans_and_stop_reports_everything() { let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let css = t.write("src/a.css", MIXED_CSS); // Grok Build PostToolUse: camelCase fields must normalize into a scan, // not skip with no-file-path (#646). let post = json!({ "hookEventName": "post_tool_use", "sessionId": "g1", "cwd": cwd, "toolName": "str_replace", "toolInput": { "file_path": css }, }) .to_string(); let one = hook::run_hook(&r, &post); assert!(one.stdout.contains("gradient-text"), "{}", one.stdout); assert_eq!(audit_str(&one.audit, "harness"), Some("grok")); // Grok discards PostToolUse stdout, so Stop must re-report the immediate // tier alongside the deferred one: the per-edit pass only touched the // file, remembering nothing. let stop = json!({ "hookEventName": "stop", "sessionId": "g1", "cwd": cwd, "reason": "end_turn" }).to_string(); let deep = hook::run_stop_hook(&r, &stop); assert!( deep.stdout.contains("[gradient-text]") && deep.stdout.contains("[side-tab]"), "{}", deep.stdout ); assert!(deep.stdout.contains("\"hookEventName\":\"Stop\""), "Grok takes the Claude Stop payload shape"); // The observe-only shutdown fire never re-emits. let shutdown = json!({ "hookEventName": "stop", "sessionId": "g1", "cwd": cwd, "reason": "shutdown" }).to_string(); let second = hook::run_stop_hook(&r, &shutdown); assert_eq!(second.stdout, ""); assert_eq!(audit_str(&second.audit, "skipped"), Some("stop-reason")); // stopHookActive (camelCase) guards re-entry like stop_hook_active. let active = json!({ "hookEventName": "stop", "sessionId": "g1", "cwd": cwd, "stopHookActive": true }).to_string(); let re = hook::run_stop_hook(&r, &active); assert_eq!(audit_str(&re.audit, "skipped"), Some("stop-hook-active")); } #[test] fn stop_cache_syncs_after_clean_scan_so_reintroductions_fire() { // 3c442af7: a finding that was fixed and then reintroduced must fire // again — the clean Stop in between rewrites the remembered set. let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let css = t.write("src/a.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_event(&cwd, &css, "s1")); let first = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(first.stdout.contains("[side-tab]"), "{}", first.stdout); // Fix it: the next Stop is clean and syncs the remembered set to empty. t.write("src/a.css", ".card { color: #333; }\n"); let clean = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert_eq!(audit_str(&clean.audit, "skipped"), Some("stop-clean")); // Reintroduce: the deep pass reports it again instead of staying silent. t.write("src/a.css", SIDE_TAB_CSS); let again = hook::run_stop_hook(&r, &stop_event(&cwd, "s1")); assert!(again.stdout.contains("[side-tab]"), "{}", again.stdout); } #[test] fn codex_stop_emits_decision_block() { // #603: Codex Stop rejects Claude's hookSpecificOutput shape; findings // that should continue the turn are a top-level blocking decision. assert_eq!(payload("findings", "Stop", "codex"), r#"{"decision":"block","reason":"findings"}"#); assert_eq!(payload(" ", "Stop", "codex"), ""); // PostToolUse keeps the shared additional-context shape. assert_eq!( payload("t", "PostToolUse", "codex"), r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"t"}}"# ); let t = Tmp::new(); let cwd = t.path(); let r = rt(&cwd); let css = t.write("src/a.css", SIDE_TAB_CSS); hook::run_hook(&r, &edit_event(&cwd, &css, "s1")); let stop = json!({ "session_id": "s1", "cwd": cwd, "hook_event_name": "Stop", "turn_id": "t-9" }).to_string(); let deep = hook::run_stop_hook(&r, &stop); let out: Value = serde_json::from_str(&deep.stdout).unwrap(); assert_eq!(out["decision"], json!("block")); assert!(out["reason"].as_str().unwrap().contains("[side-tab]")); assert!(out.get("hookSpecificOutput").is_none()); }