From 0a1e1f5ee35e58b2f4c39abfe18e331354119c92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:41:00 +0000 Subject: [PATCH] Sync generated provider output --- .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .kiro/skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .pi/skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .qoder/skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .../skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + .trae/skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + plugin/skills/impeccable/scripts/hook-lib.mjs | 108 +++++++++++++++--- .../scripts/lib/impeccable-config.mjs | 1 + 26 files changed, 1170 insertions(+), 247 deletions(-) diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.agents/skills/impeccable/scripts/lib/impeccable-config.mjs b/.agents/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.agents/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.agents/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.claude/skills/impeccable/scripts/lib/impeccable-config.mjs b/.claude/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.claude/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.claude/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.cursor/skills/impeccable/scripts/lib/impeccable-config.mjs b/.cursor/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.cursor/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.cursor/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.gemini/skills/impeccable/scripts/lib/impeccable-config.mjs b/.gemini/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.gemini/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.gemini/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.github/skills/impeccable/scripts/lib/impeccable-config.mjs b/.github/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.github/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.github/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.kiro/skills/impeccable/scripts/lib/impeccable-config.mjs b/.kiro/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.kiro/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.kiro/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.opencode/skills/impeccable/scripts/lib/impeccable-config.mjs b/.opencode/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.opencode/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.opencode/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.pi/skills/impeccable/scripts/lib/impeccable-config.mjs b/.pi/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.pi/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.pi/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.qoder/skills/impeccable/scripts/lib/impeccable-config.mjs b/.qoder/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.qoder/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.qoder/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.rovodev/skills/impeccable/scripts/lib/impeccable-config.mjs b/.rovodev/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.rovodev/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.trae-cn/skills/impeccable/scripts/lib/impeccable-config.mjs b/.trae-cn/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/.trae/skills/impeccable/scripts/lib/impeccable-config.mjs b/.trae/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/.trae/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/.trae/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 2893fa4f3..f90c2f4a8 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -70,7 +70,9 @@ export const SENSITIVE_PATH = new RegExp([ ].join('|'), 'i'); // Hard-skip regex for generated, lock, minified, and build-output paths. -export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; +// `generated` is matched as a whole path segment so authored names such as +// `generated-utils.ts` or `CodeGenerator.tsx` still get scanned. +export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i; export const TRUTHY = /^(1|true|yes|on)$/i; @@ -83,7 +85,12 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], - limits: { maxFindings: 5, maxChars: 8000 }, + // maxFileBytes: not every generated artifact lives under a path we can + // recognize. Committed browser bundles and vendored detector copies sit + // next to source and run 200KB+, while genuinely authored stylesheets in + // this codebase top out under 90KB. A single file past the ceiling is a + // bundle, and findings against a bundle are never actionable. + limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 }, }); export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([ @@ -315,6 +322,7 @@ function applyConfigSource(config, raw) { config.limits = { maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings), maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars), + maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes), }; } return config; @@ -774,6 +782,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); @@ -850,11 +859,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) { return fresh; } +// Sync the remembered set to the findings present in the scan just performed. +// +// This replaces rather than accumulates, and that is the whole point. An +// append-only set made the hook lie twice over: the pending ack counted +// history instead of the live scan, so it kept naming findings the agent had +// already fixed, and a finding that was fixed and later reintroduced was +// deduped against a stale memory and never re-reported. Forgetting what is no +// longer there is what lets the count shrink and a regression fire again. +// +// Callers must pass the complete current finding set, not just the fresh ones. export function rememberFindings(cache, sessionId, filePath, findings) { const fileEntry = ensureFile(cache, sessionId, filePath); - const known = new Set(fileEntry.findings || []); - for (const f of findings) known.add(findingCacheKey(f)); - fileEntry.findings = Array.from(known); + const keys = new Set((findings || []).map(f => findingCacheKey(f))); + fileEntry.findings = Array.from(keys); ensureSession(cache, sessionId).updatedAt = Date.now(); } @@ -1556,6 +1574,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let cleanWinner = null; const freshGroups = []; let suppressionWinner = null; + let cleanAckDeduped = false; + let skippedBytes = 0; + const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true; let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; @@ -1591,6 +1612,17 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } + const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; + if (maxFileBytes > 0) { + let size = 0; + try { size = fs.statSync(filePath).size; } catch { size = 0; } + if (size > maxFileBytes) { + skippedBytes = size; + lastSkip = 'too-large'; + continue; + } + } + if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); cacheDirty = true; @@ -1624,23 +1656,47 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = audit.findings = (findings || []).length; audit.freshFindings = fresh.length; - if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); - cacheDirty = true; - freshGroups.push({ filePath, findings: fresh }); - continue; - } - + // A detector failure tells us nothing about the file, so leave whatever + // was remembered alone rather than recording an empty scan as truth. if (detectorThrew) { detectorThrewAny = true; continue; } + // Sync the cache to this scan before deciding what to emit, so fixed + // findings stop being remembered and a reintroduced one reads as fresh. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; + + if (fresh.length > 0) { + freshGroups.push({ filePath, findings: fresh }); + continue; + } + if (filtered.length > 0 && !pendingWinner) { - const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); - pendingWinner = { filePath, known }; + // Count the live scan, not the session's history. + pendingWinner = { filePath, known: filtered.map(f => findingCacheKey(f)) }; } else if (filtered.length === 0 && !cleanWinner) { - cleanWinner = { filePath }; + // The clean ack carries no finding, only the standing steer that a + // silent hook is not a verdict on the design. Repeating it on every + // clean edit spends context to say nothing, so it fires once per file + // per session. The pending ack, which names real unresolved work, is + // deliberately left to repeat. + // + // Quiet mode emits nothing, so it must not consume the ack and leave a + // later non-quiet run in this session silent. + if (quietMode || !shouldEmitAckForFile(filePath, config)) { + cleanWinner = { filePath }; + } else if (ensureFile(cache, sessionId, filePath).cleanAcked) { + // Spent for this file. Remember it for the audit trail, but keep + // scanning: another target in this same event may still be owed an + // ack, and dropping out here would lose it. + cleanAckDeduped = true; + } else { + ensureFile(cache, sessionId, filePath).cleanAcked = true; + cleanWinner = { filePath }; + cleanAckDeduped = false; + } } } @@ -1683,7 +1739,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } - if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) { + if (quietMode) { return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } @@ -1721,7 +1777,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { + if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1738,15 +1794,29 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (pendingWinner || cleanWinner) { + if (pendingWinner) { return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); } + // Distinct from non-ui-ack so the audit log shows noise being suppressed on + // purpose rather than a file the hook could not classify. + if (cleanWinner) { + return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started }); + } + + if (cleanAckDeduped) { + return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started }); + } + if (suppressedHit) { return result({ suppressed: true, emitted: false, durationMs: Date.now() - started }); } - return result({ skipped: lastSkip, durationMs: Date.now() - started }); + return result({ + skipped: lastSkip, + ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}), + durationMs: Date.now() - started, + }); } catch (err) { return { exitCode: 0, diff --git a/plugin/skills/impeccable/scripts/lib/impeccable-config.mjs b/plugin/skills/impeccable/scripts/lib/impeccable-config.mjs index a62de8e3c..a0c2af6d3 100644 --- a/plugin/skills/impeccable/scripts/lib/impeccable-config.mjs +++ b/plugin/skills/impeccable/scripts/lib/impeccable-config.mjs @@ -500,6 +500,7 @@ export function extractFindingIgnoreValue(finding) { 'design-system-font', 'design-system-color', 'design-system-radius', + 'design-system-font-size', ]); if (!directValueRules.has(rule)) return ''; return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));