Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-07-20 03:41:00 +00:00
parent d146d2084b
commit 0a1e1f5ee3
26 changed files with 1170 additions and 247 deletions
@@ -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,
@@ -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));