diff --git a/crates/hook/src/hook.rs b/crates/hook/src/hook.rs index e3b0b6e9c..c8bf501ed 100644 --- a/crates/hook/src/hook.rs +++ b/crates/hook/src/hook.rs @@ -756,39 +756,49 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult { ); } let short = footer_mode_short(&mut cache, &session_id); - let mut attribution_note = if fresh_groups.iter().any(|group| { - group.findings.iter().any(|f| f.name.starts_with("[attribution unknown]")) - }) { + let first_unknown = fresh_groups.iter().flat_map(|group| &group.findings) + .position(|f| f.name.starts_with("[attribution unknown]")); + let mut attribution_note = if first_unknown.is_some() { format!("{ENVELOPE_PREFIX} {}", stop_baseline::UNKNOWN_NOTE) } else { String::new() }; // Findings and attribution take priority. Append the lower-priority stale // DESIGN.md notice only if it fits, without consuming its session flag. - let render = |note: &str| render_grouped_template( + let render = |note: &str, render_config: &HookConfig| render_grouped_template( rt, &fresh_groups, - &config, + render_config, &RenderOpts { cwd: Some(project_cwd.clone()), short_footer: short, reserve_chars: if note.is_empty() { 0.0 } else { (utf16_len(note) + 2) as f64 }, }, ); - let mut rendered = render(&attribution_note); + let mut rendered = render(&attribution_note, &config); if !attribution_note.is_empty() && !rendered.lines().any(|line| { line.starts_with("- ") && (line.contains("[attribution unknown]") || line.contains("[new]")) }) { // At the minimum budget, a grouped header and policy footer may crowd // out even the first finding. Shorten the notice before losing it. attribution_note = format!("{ENVELOPE_PREFIX} {}", stop_baseline::COMPACT_UNKNOWN_NOTE); - rendered = render(&attribution_note); + rendered = render(&attribution_note, &config); } // maxFindings / maxChars may also remove all unknown findings. Do not // attach their guidance to an output that only shows confirmed new debt. let shows_unknown = rendered.lines().any(|line| { line.starts_with("- ") && line.contains("[attribution unknown]") }); + if !shows_unknown { + if let Some(prefix @ 1..) = first_unknown { + // Reclaim the unused notice budget for the known-new prefix. + // Keep the unknown suffix omitted: simply expanding the budget + // could reveal an unknown finding without its required guidance. + let mut visible_config = config.clone(); + visible_config.limits.max_findings = cap_of(&config).min(prefix) as f64; + rendered = render("", &visible_config); + } + } let text = if shows_unknown { format!("{attribution_note}\n\n{rendered}") } else { rendered }; let text = append_design_system_note_once(rt, &text, &scan, &mut cache, &session_id, &config); diff --git a/crates/hook/src/hook_lib.rs b/crates/hook/src/hook_lib.rs index 866bd785d..0d57edc81 100644 --- a/crates/hook/src/hook_lib.rs +++ b/crates/hook/src/hook_lib.rs @@ -1314,7 +1314,7 @@ pub struct RenderOpts { pub reserve_chars: f64, } -fn cap_of(config: &HookConfig) -> usize { +pub(crate) fn cap_of(config: &HookConfig) -> usize { let mf = config.limits.max_findings; let mf = if mf == 0.0 || mf.is_nan() { DEFAULT_MAX_FINDINGS diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index f086b60d4..1a44d1b26 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -362,6 +362,39 @@ fn stop_baseline_small_grouped_output_keeps_finding_and_attribution() { 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();