mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 16:16:32 +03:00
Hook: stand down for the whole edit when the primary carries live markers
Field-testing the generate command on a Vite React app showed the per-file stand-down was not enough: an edit to the wrapped App.jsx skipped that file but still co-scanned the stylesheet it imports and spoke up about it (a clean ack or findings) mid-session, which is exactly the noise the stand-down exists to prevent. When the edited primary file carries the markers, the whole PostToolUse event now returns skipped: live-preview with the audit naming that primary, co-scanned stylesheets included; a marked file that is only co-scanned still skips alone. A unit case covers both, and the contract documents the event-level stand-down. AI-assisted: found by field tests and fixed with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude Fable 5
parent
da403a3410
commit
8fb7f7fec5
@@ -185,6 +185,7 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
let quiet_mode = truthy(rt.env("IMPECCABLE_HOOK_QUIET")) || config.quiet;
|
||||
let mut detector_threw_any = false;
|
||||
let mut last_skip = "no-scannable-file";
|
||||
let mut live_preview_edit: Option<String> = None;
|
||||
let mut suppressed_hit = false;
|
||||
let mut cache_dirty = false;
|
||||
let mut deferred_total: usize = 0;
|
||||
@@ -277,6 +278,14 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
}
|
||||
};
|
||||
if crate::hook_lib::has_live_preview_markers(&content) {
|
||||
// A live variant session owns this file. When it is the edited
|
||||
// (primary) file, the whole event stands down, co-scanned
|
||||
// stylesheets included: a clean ack or a finding about the
|
||||
// companion file is the same mid-session noise the stand-down
|
||||
// exists to prevent.
|
||||
if primary_files.contains(file_path) && live_preview_edit.is_none() {
|
||||
live_preview_edit = Some(file_path.clone());
|
||||
}
|
||||
last_skip = "live-preview";
|
||||
continue;
|
||||
}
|
||||
@@ -358,6 +367,17 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(file) = live_preview_edit {
|
||||
audit.insert("file".into(), Value::String(file));
|
||||
return result(
|
||||
&audit,
|
||||
vec![
|
||||
("emitted", Value::Bool(false)),
|
||||
("skipped", Value::from("live-preview")),
|
||||
("durationMs", ms_since(started)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if !fresh_groups.is_empty() {
|
||||
let scan = &scans[&fresh_groups[0].file_path];
|
||||
|
||||
@@ -2258,3 +2258,26 @@ fn before_edit_stands_down_on_live_preview_markers() {
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(out, "{\"permission\":\"allow\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_hook_stands_down_for_the_whole_edit_when_the_primary_carries_live_markers() {
|
||||
// The edited JSX carries the wrapper; the stylesheet it imports does not.
|
||||
// Co-scanning would still speak up about the stylesheet mid-session, so
|
||||
// the whole event stands down. The same files without markers prove the
|
||||
// co-scan is otherwise live.
|
||||
let t = Tmp::new();
|
||||
let cwd = t.path();
|
||||
let r = rt(&cwd);
|
||||
t.write("src/styles.css", GRADIENT_CSS);
|
||||
let plain = t.write("src/Plain.jsx", "import './styles.css';\nexport default function Plain() { return <h1 className=\"hero-title\">Hi</h1>; }\n");
|
||||
let reported = hook::run_hook(&r, &edit_event(&cwd, &plain, "s1"));
|
||||
assert!(reported.stdout.contains("[gradient-text]"), "co-scanned stylesheet is reported without markers: {}", reported.stdout);
|
||||
let wrapped = t.write(
|
||||
"src/App.jsx",
|
||||
"import './styles.css';\n{/* impeccable-variants-start ab12cd34 */}<div data-impeccable-variants=\"ab12cd34\" data-impeccable-variant-count=\"3\"></div>\n",
|
||||
);
|
||||
let skipped = hook::run_hook(&r, &edit_event(&cwd, &wrapped, "s2"));
|
||||
assert_eq!(skipped.stdout, "", "nothing is emitted while the edited file is in a live session");
|
||||
assert_eq!(skipped.audit["skipped"], json!("live-preview"));
|
||||
assert!(audit_str(&skipped.audit, "file").unwrap_or("").ends_with("src/App.jsx"), "the audit names the edited file, not the companion");
|
||||
}
|
||||
|
||||
@@ -1105,7 +1105,7 @@ Candidates in order: `<scripts>/detector/detect-antipatterns.mjs` (built skill l
|
||||
4. `config = readConfig(projectCwd)`; `enabled === false` → `'config-disabled'`.
|
||||
5. native platform → `skipped:'native-platform', platform`.
|
||||
6. `cache = readCache(projectCwd)`; `sessionId = event.session_id || 'unknown'`; detector missing → `'detector-missing'`; `scanOptions = designSystemOptions(...)`; `tiered = perEditTieringActive(config, harness)`; `quietMode = truthy(IMPECCABLE_HOOK_QUIET) || config.quiet`.
|
||||
7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`), and, once the content is read, `'live-preview'` (LIVE_PREVIEW_MARKERS). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`.
|
||||
7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`), and, once the content is read, `'live-preview'` (LIVE_PREVIEW_MARKERS; when the marked file is the edited PRIMARY, the whole event returns `{emitted:false, skipped:'live-preview'}` with `file` = that primary, co-scanned stylesheets included). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`.
|
||||
8. If `freshGroups` non-empty: `text = appendDesignSystemNoteOnce(renderGroupedTemplate(freshGroups, config, {cwd:projectCwd, footer: footerModeForSession, reserveChars: designNoteReserve}), ...)`; `commitFooterShown`; **`persistCache` always** (creates `.impeccable/` if needed); return `stdout = payload(text,'PostToolUse',harness)`, audit `{..., file: firstGroup.filePath, emitted:true, freshFiles, freshFindings(total), chars, durationMs}`, `emission:{kind:'fresh', file, findings, groups}`.
|
||||
9. Else compute `ack`: not quiet AND pendingWinner AND ack-eligible → `{kind:'pending', text: appendDesignSystemNoteOnce(renderPendingAck(...))}`; else not quiet AND no suppressionWinner AND cleanWinner AND !cleanAckDeduped AND ack-eligible → `{kind:'clean', text: appendDesignSystemNoteOnce(renderCleanAck(...))}`.
|
||||
10. Persist cache only if `deferredTotal > 0 || (cacheDirty && exists(<projectCwd>/.impeccable))` (a clean edit in a project with no `.impeccable/` footprint writes nothing to disk).
|
||||
|
||||
Reference in New Issue
Block a user