diff --git a/crates/hook/src/hook.rs b/crates/hook/src/hook.rs index 291567cac..da0e3d655 100644 --- a/crates/hook/src/hook.rs +++ b/crates/hook/src/hook.rs @@ -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 = 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]; diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index e8e470478..af61f9559 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -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

Hi

; }\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 */}
\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"); +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 0889331c9..3b29b1041 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1105,7 +1105,7 @@ Candidates in order: `/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(/.impeccable))` (a clean edit in a project with no `.impeccable/` footprint writes nothing to disk).