Improve hook false-positive handling

This commit is contained in:
Paul Bakaus
2026-06-15 13:30:28 +09:00
parent a9c15481a9
commit 858b9bbea6
90 changed files with 1072 additions and 416 deletions
+57 -25
View File
@@ -336,15 +336,20 @@ describe('filterFindings()', () => {
const findings = [
finding('overused-font', 1, { snippet: 'Primary font: Inter (86% of text)' }),
finding('overused-font', 2, { snippet: 'Primary font: Roboto' }),
finding('bounce-easing', 3, { snippet: 'animation: bounce-ball' }),
finding('bounce-easing', 4, { snippet: 'animation: wobble-card' }),
finding('side-tab', 3),
];
const filtered = filterFindings(findings, '', '.css', {
ignoreRules: [],
ignoreValues: [{ rule: 'overused-font', value: 'inter' }],
ignoreValues: [
{ rule: 'overused-font', value: 'inter' },
{ rule: 'bounce-easing', value: 'bounce-ball' },
],
minSeverity: 'warning',
limits: DEFAULT_CONFIG.limits,
});
assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['overused-font:2', 'side-tab:3']);
assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['overused-font:2', 'bounce-easing:4', 'side-tab:3']);
});
it('extracts overused-font values from primary, CSS, and Google font snippets', () => {
@@ -362,6 +367,21 @@ describe('filterFindings()', () => {
);
assert.equal(extractFindingIgnoreValue(finding('side-tab', 1)), '');
});
it('extracts bounce-easing values from motion snippets', () => {
assert.equal(
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'animation: bounce-ball' })),
'bounce-ball',
);
assert.equal(
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'animate-bounce (Tailwind)' })),
'animate-bounce',
);
assert.equal(
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'cubic-bezier(0.3, -0.4, 0.6, 1.4)' })),
'cubic-bezier(0.3, -0.4, 0.6, 1.4)',
);
});
});
describe('hook-admin.mjs', () => {
@@ -529,7 +549,7 @@ describe('renderTemplate()', () => {
const findings = Array.from({ length: 12 }, (_, i) =>
finding('side-tab', i + 1, { name: `R${i}`, description: 'd' }));
const text = renderTemplate(findings, '/x/Card.tsx', DEFAULT_CONFIG, { cwd: '/x' });
assert.ok(text.startsWith(`${ENVELOPE_PREFIX} Required design corrections in Card.tsx (12 issue(s)):`));
assert.ok(text.startsWith(`${ENVELOPE_PREFIX} Design hook findings requiring review in Card.tsx (12 issue(s)):`));
assert.match(text, /\.\.\. and 7 more \(see \/impeccable audit\)\./);
// Exactly 5 finding lines.
const lines = text.split('\n').filter((l) => l.startsWith('- '));
@@ -537,19 +557,23 @@ describe('renderTemplate()', () => {
assert.ok(text.length <= DEFAULT_CONFIG.limits.maxChars);
});
it('emits a directive footer (imperative + exception clause + confirmed ignore guidance)', () => {
// Steers the model: imperative "fix", explicit exception for
// intentional bad UI / fixtures, and "acknowledge" so the user
// sees the correction in the chat reply. See `directiveFooter()`
// in hook-lib.mjs for the rationale.
it('emits a directive footer (imperative + judgment clause + confirmed ignore guidance)', () => {
// Steers the model: imperative "handle", explicit context judgment
// before editing, and "acknowledge" so the user sees the resolution
// in the chat reply. See `directiveFooter()` in hook-lib.mjs for
// the rationale.
const text = renderTemplate(
[finding('side-tab', 1, { name: 'X' })],
'/x/Card.tsx', DEFAULT_CONFIG, { cwd: '/x' }
);
assert.match(text, /Fix these in your next reply/);
assert.match(text, /Acknowledge what you changed/);
assert.match(text, /intentionally bad UI|anti-pattern example|test fixture/);
assert.match(text, /Do not add hook ignores unless the user explicitly confirms/);
assert.match(text, /Handle these before finalizing/);
assert.match(text, /fix findings that are real design problems/);
assert.match(text, /classify contextually intentional findings as false positives/);
assert.match(text, /Use context judgment before editing/);
assert.match(text, /not automatically a defect/);
assert.match(text, /literal or domain-appropriate motion/);
assert.match(text, /Do not change intentional design just to satisfy the hook/);
assert.match(text, /Persist hook ignores only after the user explicitly confirms/);
assert.match(text, /Do not add source comments such as `impeccable: ignore`/);
assert.match(text, /ignore-value \.\.\. --shared/);
assert.match(text, /ignore-rule overused-font --all-values/);
@@ -567,6 +591,14 @@ describe('renderTemplate()', () => {
assert.match(text, /ignore-rule overused-font --all-values/);
});
it('shows the exact value-specific command for bounce-easing findings', () => {
const text = renderTemplate(
[finding('bounce-easing', 1, { name: 'Bounce or elastic easing', snippet: 'animation: bounce-ball' })],
'/x/main.css', DEFAULT_CONFIG, { cwd: '/x' }
);
assert.match(text, /\/impeccable hooks ignore-value bounce-easing bounce-ball --shared/);
});
it('drops the L<line> prefix when line is 0', () => {
const text = renderTemplate(
[finding('side-tab', 0, { name: 'X' })],
@@ -706,13 +738,13 @@ describe('runHook()', () => {
const r1 = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.equal(r1.exitCode, 0);
assert.ok(r1.stdout.includes(ENVELOPE_PREFIX));
assert.match(r1.stdout, /Required design corrections/);
assert.match(r1.stdout, /Design hook findings requiring review/);
assert.equal(r1.audit.emitted, true);
const r2 = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.equal(r2.exitCode, 0);
assert.ok(r2.stdout.includes(ENVELOPE_PREFIX));
assert.match(r2.stdout, /Still has 1 issue\(s\) flagged earlier this session/);
assert.match(r2.stdout, /Still has 1 finding\(s\) flagged earlier this session/);
assert.match(r2.stdout, /side-tab:1/);
assert.equal(r2.audit.emitted, true);
assert.equal(r2.audit.kind, 'pending');
@@ -754,7 +786,7 @@ describe('runHook()', () => {
cwd,
detector: fakeDetector([finding('side-tab', 1)]),
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /side-tab/);
});
@@ -762,7 +794,7 @@ describe('runHook()', () => {
const file = writeFixture('src/build.js', 'export const value = 1;');
const det = fakeDetector([finding('side-tab', 1)]);
const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.match(first.stdout, /Required design corrections/);
assert.match(first.stdout, /Design hook findings requiring review/);
const second = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.equal(second.stdout, '');
@@ -793,7 +825,7 @@ describe('runHook()', () => {
env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: detFindings,
});
assert.ok(rFindings.stdout.includes(ENVELOPE_PREFIX));
assert.match(rFindings.stdout, /Required design corrections/);
assert.match(rFindings.stdout, /Design hook findings requiring review/);
assert.equal(rFindings.audit.emitted, true);
});
@@ -965,7 +997,7 @@ describe('runHook()', () => {
const det = fakeDetector([finding('side-tab', 1)]);
const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd, detector: det });
assert.equal(r.exitCode, 0);
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
});
it('detector throw is swallowed; never breaks turn', async () => {
@@ -989,7 +1021,7 @@ describe('runHook()', () => {
cwd,
detector: { detectHtml, detectText },
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.doesNotMatch(r.stdout, /No anti-patterns/);
assert.ok(r.audit.findings > 0);
});
@@ -1053,10 +1085,10 @@ describe('renderCleanAck() / renderPendingAck()', () => {
const known = ['side-tab:3', 'gradient-text:4', 'ai-color-palette:8', 'overused-font:12'];
const text = renderPendingAck('/x/src/SlopCard.jsx', known, { cwd: '/x' });
assert.match(text, /^\[impeccable@1\] Design hook scanned src\/SlopCard\.jsx\./);
assert.match(text, /Still has 4 issue\(s\) flagged earlier this session/);
assert.match(text, /Still has 4 finding\(s\) flagged earlier this session/);
assert.match(text, /side-tab:3, gradient-text:4, ai-color-palette:8/);
assert.match(text, /\+1 more/); // 4 total, 3 shown
assert.match(text, /Address them before finalizing/);
assert.match(text, /Handle them before finalizing/);
});
it('renderPendingAck omits the "+N more" suffix when ≤3 known findings', () => {
@@ -1229,7 +1261,7 @@ describe('runHook() — co-located stylesheet scan', () => {
cwd,
detector: det,
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /styles\.css/);
});
@@ -1254,7 +1286,7 @@ describe('runHook() — co-located stylesheet scan', () => {
cwd,
detector: det,
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /styles\.sass/);
});
@@ -1285,7 +1317,7 @@ describe('runHook() — co-located stylesheet scan', () => {
detector: det,
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /App\.jsx/);
assert.match(r.stdout, /styles\.css/);
assert.match(r.stdout, /side-tab/);
@@ -1415,7 +1447,7 @@ describe('Cursor hook scripts', () => {
assert.equal(payload.permission, 'deny');
assert.match(payload.user_message, /blocked this write/);
assert.match(payload.user_message, /side-tab/);
assert.match(payload.agent_message, /Fix these in your next reply/);
assert.match(payload.agent_message, /Handle these before finalizing/);
const entries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').map((line) => JSON.parse(line));
assert.equal(entries[0].event, 'preToolUse');