Compare commits

...
Author SHA1 Message Date
Paul Bakaus af889a59d8 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.
2026-09-06 16:55:43 -07:00
Paul Bakaus 2595fc8e58 Fix: prioritize Stop findings over notices
Keep stale design reminders opportunistic, compact attribution guidance at small budgets, and emit unknown guidance only for displayed findings. Add regressions for deduplication, finding caps, and single/grouped output limits.

AI assistance: Codex, under maintainer direction.
2026-09-06 16:45:50 -07:00
Paul Bakaus f5c7e294ab Fix: distinguish existing Stop findings from new debt
Capture verified first-edit Claude text baselines without consulting Git HEAD or persisting source. Suppress matching prior findings; label new and unknown attribution, keeping dependency-sensitive and unsupported inputs unknown. Retire exemptions after a fix or scan suppression. Leave explicit scans and per-edit behavior unchanged.

Related to #522. Added regressions and two engine oracle cases; reviewed four existing Stop golden updates for attribution-only output changes. Generated provider output intentionally omitted.

AI assistance: Codex, under maintainer direction.
2026-09-06 16:36:17 -07:00
14 changed files with 777 additions and 15 deletions
+2
View File
@@ -419,6 +419,8 @@ Codex requires one platform step that Impeccable cannot safely skip: open `/hook
Full hook docs: [impeccable.style/docs/hooks](https://impeccable.style/docs/hooks).
The Stop pass suppresses confirmed pre-existing findings when a verified before-edit baseline is available (currently Claude Edit/Write results for text scans). Other findings are marked new or attribution unknown; unknown is not evidence that your session caused the problem. Explicit `detect` scans remain unchanged.
Manual copy commands are fallback/debug instructions. The normal path is:
```bash
+64 -10
View File
@@ -7,6 +7,7 @@ use impeccable_core::js;
use serde_json::{Map, Value};
use crate::hook_lib::*;
use crate::stop_baseline;
use crate::util::{
exists, iso_now, jsp, node_read_error, now_ms, str_field, truthy_value, utf16_len,
};
@@ -237,11 +238,19 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
}
}
let use_html_engine = match configured {
Some(c) => c.engine == "html",
None => ext == ".html" || ext == ".htm",
};
if primary_files.contains(file_path) {
if harness == "claude" {
stop_baseline::capture(rt, &event, &mut cache, &session_id, file_path, use_html_engine);
}
let edit_count = bump_edit_count(&mut cache, &session_id, file_path);
cache_dirty = true;
audit.insert("editCount".into(), Value::from(edit_count as u64));
if edit_count > EDIT_COUNT_THRESHOLD as f64 {
stop_baseline::invalidate(&mut cache, &session_id, file_path);
let just_crossed = edit_count == (EDIT_COUNT_THRESHOLD + 1) as f64;
if just_crossed && suppression_winner.is_none() {
suppression_winner = Some(file_path.clone());
@@ -266,10 +275,6 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
};
}
};
let use_html_engine = match configured {
Some(c) => c.engine == "html",
None => ext == ".html" || ext == ".htm",
};
let mut detector_threw = false;
let findings: Vec<Finding> = if use_html_engine {
match detector_detect_html(rt, file_path, &scan) {
@@ -282,6 +287,9 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
} else {
detector_detect_text(&content, file_path, &scan)
};
if !detector_threw && !use_html_engine {
stop_baseline::reconcile(&mut cache, &session_id, file_path, &findings);
}
let raw_count = findings.len();
let filtered = filter_findings(findings, &config);
let (immediate, deferred) = if tiered {
@@ -660,6 +668,9 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
let mut fresh_groups: Vec<Group> = Vec::new();
let mut scanned = 0usize;
let mut cache_dirty = false;
let mut pre_existing = 0usize;
let mut new_findings = 0usize;
let mut unknown = 0usize;
for file_path in &touched {
if scanned >= STOP_MAX_FILES {
break;
@@ -705,8 +716,15 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
} else {
detector_detect_text(&content, file_path, &scan)
};
if !use_html_engine {
stop_baseline::reconcile(&mut cache, &session_id, file_path, &findings);
}
let filtered = filter_findings(findings, &config);
let fresh = dedupe_against_cache(&filtered, &mut cache, &session_id, file_path);
let classified = stop_baseline::classify(&cache, &session_id, file_path, use_html_engine, filtered.clone());
pre_existing += classified.pre_existing;
new_findings += classified.new;
unknown += classified.unknown;
let fresh = dedupe_against_cache(&classified.findings, &mut cache, &session_id, file_path);
// JS: sync to the live scan, including empty. Remembering only
// `fresh` (or skipping the write on a clean Stop) left stale keys in
// place, so a finding that was fixed and later reintroduced never
@@ -721,6 +739,9 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
}
}
audit.insert("scannedFiles".into(), Value::from(scanned));
audit.insert("preExistingFindings".into(), Value::from(pre_existing));
audit.insert("newFindings".into(), Value::from(new_findings));
audit.insert("unknownFindings".into(), Value::from(unknown));
if fresh_groups.is_empty() {
if cache_dirty {
persist_cache(rt, &project_cwd, &cache);
@@ -735,19 +756,52 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
);
}
let short = footer_mode_short(&mut cache, &session_id);
let reserve = design_note_reserve(rt, &scan, &mut cache, &session_id);
let rendered = render_grouped_template(
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_config: &HookConfig| render_grouped_template(
rt,
&fresh_groups,
&config,
render_config,
&RenderOpts {
cwd: Some(project_cwd.clone()),
short_footer: short,
reserve_chars: reserve,
reserve_chars: if note.is_empty() { 0.0 } else { (utf16_len(note) + 2) as f64 },
},
);
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, &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, &rendered, &scan, &mut cache, &session_id, &config);
append_design_system_note_once(rt, &text, &scan, &mut cache, &session_id, &config);
commit_footer_shown(rt, &mut cache, &session_id, &text);
persist_cache(rt, &project_cwd, &cache);
let all: usize = fresh_groups.iter().map(|g| g.findings.len()).sum();
+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
+1
View File
@@ -14,6 +14,7 @@ pub mod admin;
pub mod before_edit;
pub mod hook;
pub mod hook_lib;
mod stop_baseline;
pub mod util;
use impeccable_common::Io;
+279
View File
@@ -0,0 +1,279 @@
//! Conservative Stop attribution. Never use HEAD as a session baseline: the
//! working tree may already be dirty. Only a verified first Edit/Write preimage
//! from the tool result establishes a baseline, and only for the pure text
//! detector. DOM and design-system findings can depend on other files.
use impeccable_core::findings::Finding;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use crate::hook_lib::{
detector_detect_text, ensure_file, sessions, Cache, HookScanOptions, Runtime,
};
const FIELD: &str = "stopBaseline";
const MAX_BYTES: usize = 512 * 1024;
const MAX_FINDINGS: usize = 256;
pub const UNKNOWN_NOTE: &str = "Findings marked attribution unknown may predate this session; do not treat them as regressions or broaden the task without asking.";
pub const COMPACT_UNKNOWN_NOTE: &str = "Unknown findings may predate this session; ask before expanding scope.";
fn independent(finding: &Finding) -> bool {
!finding.antipattern.starts_with("design-system-")
}
// Exclude line numbers so an unrelated insertion/deletion does not make old
// debt new. Preserve multiplicity so adding an identical occurrence is new.
// Hash the detector identity rather than storing source or snippets in cache.
fn key(finding: &Finding) -> String {
let identity = json!([finding.antipattern, finding.snippet, finding.extras]);
format!("{:x}", Sha256::digest(identity.to_string().as_bytes()))
}
fn counts(findings: &[Finding]) -> Map<String, Value> {
let mut counts = Map::new();
for finding in findings.iter().filter(|f| independent(f)) {
let key = key(finding);
let count = counts.get(&key).and_then(Value::as_u64).unwrap_or(0);
counts.insert(key, Value::from(count + 1));
}
counts
}
fn entry<'a>(cache: &'a Cache, session: &str, file: &str) -> Option<&'a Map<String, Value>> {
sessions(cache)?
.get(session)?
.get("files")?
.get(file)?
.as_object()
}
fn baseline(cache: &Cache, session: &str, file: &str) -> Option<Map<String, Value>> {
let value = entry(cache, session, file)?.get(FIELD)?;
if value.get("version")?.as_u64()? != 1
|| value.get("engine")?.as_str()? != env!("CARGO_PKG_VERSION")
{
return None;
}
let counts = value.get("counts")?.as_object()?;
if counts.len() > MAX_FINDINGS
|| counts.iter().any(|(k, v)| {
k.len() != 64
|| !k.bytes().all(|b| b.is_ascii_hexdigit())
|| !matches!(v.as_u64(), Some(1..=256))
})
{
return None;
}
Some(counts.clone())
}
/// Called before the first primary edit is recorded. An entry without a
/// baseline (old cache, co-scan, incomplete payload) must stay unknown rather
/// than adopting a later, already-edited file as its starting point.
pub fn capture(
rt: &Runtime,
event: &Map<String, Value>,
cache: &mut Cache,
session: &str,
file: &str,
html: bool,
) {
if html || session.is_empty() || session == "unknown" || entry(cache, session, file).is_some() {
return;
}
let Some(response) = event.get("tool_response").and_then(Value::as_object) else {
return;
};
let Some(path) = response.get("filePath").and_then(Value::as_str) else {
return;
};
let cwd = event
.get("cwd")
.and_then(Value::as_str)
.unwrap_or(&rt.proc_cwd);
if rt.resolve(&[cwd, path]) != file || response.get("userModified") == Some(&Value::Bool(true))
{
return;
}
let Some(tool) = event.get("tool_name").and_then(Value::as_str) else {
return;
};
let original = match response.get("originalFile") {
Some(Value::String(text)) if text.len() <= MAX_BYTES => text.as_str(),
// null on an update may mean "too large", not an empty original.
Some(Value::Null)
if tool == "Write"
&& response.get("type").and_then(Value::as_str) == Some("create") =>
{
""
}
_ => return,
};
let expected = match tool {
"Edit" => {
let Some(old) = response.get("oldString").and_then(Value::as_str) else {
return;
};
let Some(new) = response.get("newString").and_then(Value::as_str) else {
return;
};
if old.is_empty() || new.len() > MAX_BYTES || !original.contains(old) {
return;
}
match response.get("replaceAll").and_then(Value::as_bool) {
Some(true) => {
let occurrences = original.matches(old).count();
let size = original.len() - occurrences * old.len()
+ occurrences.saturating_mul(new.len());
if size > MAX_BYTES {
return;
}
original.replace(old, new)
}
Some(false) if original.matches(old).count() == 1 => original.replacen(old, new, 1),
_ => return,
}
}
"Write" => {
if !matches!(
response.get("type").and_then(Value::as_str),
Some("create" | "update")
) {
return;
}
let Some(content) = response.get("content").and_then(Value::as_str) else {
return;
};
if content.len() > MAX_BYTES {
return;
}
content.to_string()
}
_ => return,
};
if expected.len() > MAX_BYTES
|| std::fs::metadata(file)
.map(|m| m.len() > MAX_BYTES as u64)
.unwrap_or(true)
{
return;
}
// A formatter, stale event, or concurrent write invalidates attribution.
if std::fs::read_to_string(file).ok().as_deref() != Some(expected.as_str()) {
return;
}
let findings = detector_detect_text(original, file, &HookScanOptions::default());
if findings.len() > MAX_FINDINGS {
return;
}
ensure_file(cache, session, file).insert(
FIELD.into(),
json!({
"version": 1, "engine": env!("CARGO_PKG_VERSION"), "counts": counts(&findings),
}),
);
}
/// Once existing debt disappears, it cannot exempt a later reintroduction.
pub fn reconcile(cache: &mut Cache, session: &str, file: &str, findings: &[Finding]) {
let Some(mut old) = baseline(cache, session, file) else {
return;
};
let current = counts(findings);
old.retain(|key, value| {
let count = current
.get(key)
.and_then(Value::as_u64)
.unwrap_or(0)
.min(value.as_u64().unwrap_or(0));
*value = Value::from(count);
count > 0
});
ensure_file(cache, session, file).get_mut(FIELD).unwrap()["counts"] = Value::Object(old);
}
/// Do not retain an exemption through edits we deliberately stop scanning.
pub fn invalidate(cache: &mut Cache, session: &str, file: &str) {
ensure_file(cache, session, file).remove(FIELD);
}
#[derive(Default)]
pub struct Classified {
pub findings: Vec<Finding>,
pub pre_existing: usize,
pub new: usize,
pub unknown: usize,
}
pub fn classify(
cache: &Cache,
session: &str,
file: &str,
html: bool,
findings: Vec<Finding>,
) -> Classified {
let mut baseline = if html {
None
} else {
baseline(cache, session, file)
};
let mut result = Classified::default();
for mut finding in findings {
let known = baseline.as_mut().filter(|_| independent(&finding));
if let Some(counts) = known {
let key = key(&finding);
let count = counts.get(&key).and_then(Value::as_u64).unwrap_or(0);
if count > 0 {
counts.insert(key, Value::from(count - 1));
result.pre_existing += 1;
continue;
}
result.new += 1;
finding.name = format!("[new] {}", finding.name);
} else {
result.unknown += 1;
finding.name = format!("[attribution unknown] {}", finding.name);
}
result.findings.push(finding);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use impeccable_core::findings::finding;
#[test]
fn stop_baseline_dependency_sensitive_findings_stay_unknown() {
let mut cache = Cache::new();
let font = finding("design-system-font", "a.css", "font-family: serif", 1.0);
let css = finding("side-tab", "a.html", "border-left: 4px solid red", 1.0);
ensure_file(&mut cache, "s", "a.css").insert(
FIELD.into(),
json!({
"version": 1, "engine": env!("CARGO_PKG_VERSION"), "counts": {},
}),
);
// Even a known text preimage cannot establish the state of DESIGN.md
// before the session, or of the stylesheets a DOM scan reads.
assert_eq!(classify(&cache, "s", "a.css", false, vec![font]).unknown, 1);
assert_eq!(classify(&cache, "s", "a.css", true, vec![css]).unknown, 1);
}
#[test]
fn stop_baseline_old_engine_and_malformed_cache_stay_unknown() {
let mut cache = Cache::new();
let f = finding("side-tab", "a.css", "border-left: 4px solid red", 1.0);
for record in [
json!({"version": 1, "engine": "0.0.0", "counts": {}}),
json!({"version": 1, "engine": env!("CARGO_PKG_VERSION"), "counts": {"bad": -1}}),
] {
ensure_file(&mut cache, "s", "a.css").insert(FIELD.into(), record);
assert_eq!(
classify(&cache, "s", "a.css", false, vec![f.clone()]).unknown,
1
);
}
}
}
+344
View File
@@ -124,6 +124,350 @@ fn stop_event(cwd: &str, session: &str) -> 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 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 = `<style>body { font-family: Fraunces; }</style>`;\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 = () => <div>Before</div>;\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<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();
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<String, Value>, k: &str) -> Option<&'a str> {
a.get(k).and_then(Value::as_str)
}
+10
View File
@@ -1125,6 +1125,16 @@ Candidates in order: `<scripts>/detector/detect-antipatterns.mjs` (built skill l
---
#### Stop finding attribution (#522)
The Rust Stop pass still scans whole touched files; it does not infer causation from changed line ranges. It suppresses confirmed pre-existing text findings and marks remaining findings `[new]` or `[attribution unknown]`. Unknown findings remain visible, with a reminder not to treat them as regressions or broaden the task without asking. Explicit `detect` scans and per-edit/pre-edit output are unchanged.
A baseline currently requires the first observed edit of that file in a named Claude session to carry a complete `Edit`/`Write` result (`tool_response.originalFile`, `filePath`, and the replacement/content fields). Replaying that result must exactly match the current file. `Write` with `type: "create"` and a null original is an empty baseline; a null original on an update is unknown. No Git/HEAD comparison is used, so existing uncommitted work is part of the baseline. Missing, ambiguous, oversized, mismatched, or user-modified results remain unknown; a later edit cannot establish a missing initial baseline.
The baseline compares pure text-detector findings, independent of line numbers, with multiplicity preserved. It stores only hashed finding identities and counts under the existing session cache's `stopBaseline` field, versioned by schema and engine. A finding observed to disappear loses its exemption, so reintroducing it is new. Capture is capped at 512 KiB per file and 256 findings. Cache eviction or incompatible metadata falls back to unknown, never suppression.
DOM scans, design-system findings, co-scanned stylesheets without their own baseline, and providers/events without a verified preimage remain unknown: other files or earlier edits may affect the result. `[new]` means absent from the verified first-observed-edit baseline, not proof of who caused it. Stop audit output records `preExistingFindings` (suppressed), `newFindings`, and `unknownFindings` before notification deduplication. No permanent ignores or new hook permissions are created.
#### `hook-before-edit.mjs` -> `impeccable hook-before-edit` (Cursor preToolUse write gate)
- **Invoked from**: Cursor project manifest `.cursor/hooks.json`:
+34
View File
@@ -17,6 +17,40 @@ const claudeEdit = (file, extra = {}) => ({
const stop = (extra = {}) => ({ session_id: 's1', cwd: WS, hook_event_name: 'Stop', stop_hook_active: false, ...extra });
export default [
{
id: 'hook-stop-baseline-new-finding', workspace: 'hook-project', files: CACHE_FILES,
normalize: [['("stopBaseline":\\{"version":1,"engine":")[^"]+', 'g', '$1<ENGINE_VERSION>']],
setup(ws) {
fs.writeFileSync(`${ws}/src/new.css`, '.card { border-left: 4px solid #6366f1; border-radius: 8px; }\n');
},
steps: [
{ verb: 'hook', stdin: claudeEdit('src/new.css', {
tool_name: 'Write',
tool_response: {
type: 'create', filePath: `${WS}/src/new.css`, originalFile: null,
content: '.card { border-left: 4px solid #6366f1; border-radius: 8px; }\n',
},
}) },
{ verb: 'hook', stdin: stop() },
],
},
{
id: 'hook-stop-baseline-import-only', workspace: 'hook-project', files: CACHE_FILES,
normalize: [['("stopBaseline":\\{"version":1,"engine":")[^"]+', 'g', '$1<ENGINE_VERSION>']],
setup(ws) {
fs.writeFileSync(`${ws}/src/report.ts`, "const report = `<style>body { font-family: Fraunces; }</style>`;\n");
},
steps: [
{ verb: 'hook', stdin: claudeEdit('src/report.ts', {
tool_response: {
filePath: `${WS}/src/report.ts`,
originalFile: "import dead from 'dead';\nconst report = `<style>body { font-family: Fraunces; }</style>`;\n",
oldString: "import dead from 'dead';\n", newString: '', replaceAll: false, userModified: false,
},
}) },
{ verb: 'hook', stdin: stop() },
],
},
// --- hook.mjs: per-edit ---
{ id: 'hook-edit-tsx-fresh', verb: 'hook', workspace: 'hook-project', stdin: claudeEdit('src/components/Card.tsx'), files: CACHE_FILES },
{ id: 'hook-edit-css-fresh', verb: 'hook', workspace: 'hook-project', stdin: claudeEdit('src/components/Card.module.css'), files: CACHE_FILES },
@@ -7,7 +7,7 @@
"signal": null
},
{
"stdout": "{\"decision\":\"block\",\"reason\":\"[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (1 issue(s)):\\n- L1 [side-tab] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}",
"stdout": "{\"decision\":\"block\",\"reason\":\"[impeccable@1] Findings marked attribution unknown may predate this session; do not treat them as regressions or broaden the task without asking.\\n\\n[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (1 issue(s)):\\n- L1 [side-tab] [attribution unknown] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}",
"stderr": "",
"exit": 0,
"signal": null
@@ -25,7 +25,7 @@
"signal": null
},
{
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"additionalContext\":\"[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (1 issue(s)):\\n- L1 [side-tab] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}}",
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"additionalContext\":\"[impeccable@1] Findings marked attribution unknown may predate this session; do not treat them as regressions or broaden the task without asking.\\n\\n[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (1 issue(s)):\\n- L1 [side-tab] [attribution unknown] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}}",
"stderr": "",
"exit": 0,
"signal": null
@@ -7,7 +7,7 @@
"signal": null
},
{
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"additionalContext\":\"[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (2 issue(s)):\\n- L1 [side-tab] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n- L2 [gradient-text] Gradient text. Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}}",
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"additionalContext\":\"[impeccable@1] Findings marked attribution unknown may predate this session; do not treat them as regressions or broaden the task without asking.\\n\\n[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (2 issue(s)):\\n- L1 [side-tab] [attribution unknown] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n- L2 [gradient-text] [attribution unknown] Gradient text. Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}}",
"stderr": "",
"exit": 0,
"signal": null
@@ -13,7 +13,7 @@
"signal": null
},
{
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"additionalContext\":\"[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (1 issue(s)):\\n- L1 [side-tab] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}}",
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"additionalContext\":\"[impeccable@1] Findings marked attribution unknown may predate this session; do not treat them as regressions or broaden the task without asking.\\n\\n[impeccable@1] Design hook findings requiring review in src/components/Card.module.css (1 issue(s)):\\n- L1 [side-tab] [attribution unknown] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n\\nTriage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `impeccable hooks ignore-value` and disclose them in your reply; unsure, ask in one line.\"}}",
"stderr": "",
"exit": 0,
"signal": null
@@ -0,0 +1,19 @@
{
"steps": [
{
"stdout": "",
"stderr": "",
"exit": 0,
"signal": null
},
{
"stdout": "",
"stderr": "",
"exit": 0,
"signal": null
}
],
"files": {
".impeccable/hook.cache.json": "{\"version\":1,\"sessions\":{\"s1\":{\"updatedAt\": <EPOCH>,\"files\":{\"<WS>/src/report.ts\":{\"editCount\":1,\"findings\":[\"overused-font:1:fraunces\"],\"stopBaseline\":{\"version\":1,\"engine\":\"<ENGINE_VERSION>\",\"counts\":{\"57802aad69167e30e1dc001298a4ad9552f238c951be5b990b3071d6e2bdf827\":1}}}}}}}"
}
}
@@ -0,0 +1,19 @@
{
"steps": [
{
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"[impeccable@1] Design hook scanned src/new.css. No deterministic design-quality issues found. That does not mean the design is good: keep following the project design system and the impeccable skill guidance.\"}}",
"stderr": "",
"exit": 0,
"signal": null
},
{
"stdout": "{\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"additionalContext\":\"[impeccable@1] Design hook findings requiring review in src/new.css (1 issue(s)):\\n- L1 [side-tab] [new] Side-tab accent border. Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\\n\\nTriage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:\\n- Real design problem: fix it. Keep intentional design as designed.\\n- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run `<HOOK_ADMIN_CMD> ignore-value <rule> \\\"<value>\\\" --reason \\\"<who decided: evidence>\\\"` with the pair shown on the finding line, or value \\\"*\\\" plus `--file <path>` when the line shows none. Write \\\"user confirmed\\\" in a reason only when the user did.\\n- Unsure: leave it as is and ask the user in one line.\\nSelf-serve ends at ignore-value: `ignore-file` and `ignore-rule` need the user's explicit approval, and never add an ignore to push a blocked write through. Full suppression ladder: /impeccable hooks.\"}}",
"stderr": "",
"exit": 0,
"signal": null
}
],
"files": {
".impeccable/hook.cache.json": "{\"version\":1,\"sessions\":{\"s1\":{\"updatedAt\": <EPOCH>,\"files\":{\"<WS>/src/new.css\":{\"editCount\":1,\"findings\":[\"side-tab:1\"],\"stopBaseline\":{\"version\":1,\"engine\":\"<ENGINE_VERSION>\",\"counts\":{}},\"cleanAcked\":true}},\"footerShown\":true}}}"
}
}