Fix: reclaim unused Stop notice budget

Re-render the displayed new-finding prefix without a discarded attribution reserve. Keep omitted unknown findings behind the cap so reclaimed space cannot expose them without guidance. Cover both finding-count and character limits.

AI assistance: Codex, under maintainer direction.
This commit is contained in:
Paul Bakaus
2026-09-06 16:55:43 -07:00
parent 2595fc8e58
commit af889a59d8
3 changed files with 51 additions and 8 deletions
+17 -7
View File
@@ -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);
+1 -1
View File
@@ -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
+33
View File
@@ -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<Group> = [(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();