/** * Unit tests for the Impeccable design hook. * Run: node --test tests/hook.test.mjs * * Exercises hook-lib.mjs through `runHook()` with an injected detector so the * suite stays fast and detector-independent. A second block exercises the * library helpers (config, cache, filter, render) directly. */ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { execFileSync } from 'node:child_process'; import { ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, DEFAULT_CONFIG, SENSITIVE_PATH, GENERATED_PATH, truthy, getConfigPath, getLocalConfigPath, getCachePath, getPendingPath, ensureHookGitExcludes, readConfig, readCache, persistCache, resolveCacheCwd, bumpEditCount, rememberFindings, dedupeAgainstCache, filterFindings, renderTemplate, renderCleanAck, renderPendingAck, shouldEmitAckForFile, matchConfiguredExtension, matchesAnyGlob, writeAuditLog, suppressionNotice, parseApplyPatchPaths, resolveTargetFiles, resolveHarness, normalizeHookEvent, isStopEvent, expandScanTargets, parseStaticStyleImports, coLocatedStylesheets, runHook, runStopHook, commitFooterShown, IMMEDIATE_TIER_RULES, splitFindingsByTier, perEditTieringActive, ADVISORY_RULES, isAdvisoryFinding, payload, extractFindingIgnoreValue, resolveProjectPlatform, isNativePlatform, normalizeIgnoreValueEntries, isScanTargetInsideProject, } from '../skill/scripts/hook-lib.mjs'; import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs'; import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs'; // Hook state paths are env-sensitive: an ambient IMPECCABLE_CACHE_ROOT (a // developer using the redirect locally) would relocate cache/pending out of // the tmp projects and break stock-path assertions. Clear it up front; the // dedicated issue-#422 suite sets and restores it explicitly. delete process.env.IMPECCABLE_CACHE_ROOT; function mkTmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-')); } function fakeDetector(findings) { return { detectText: () => findings, detectHtml: () => findings, }; } function finding(id, line, extras = {}) { return { antipattern: id, name: extras.name || 'Test finding', description: extras.description || 'A test finding description.', severity: extras.severity || 'warning', file: extras.file || 'src/Card.tsx', line, snippet: extras.snippet || '', }; } describe('truthy()', () => { it('matches the documented values, case-insensitive', () => { for (const v of ['1', 'true', 'TRUE', 'yes', 'YES', 'on', 'On']) { assert.equal(truthy(v), true, `expected truthy("${v}")`); } for (const v of ['', '0', 'false', 'no', 'off', 'yep', undefined, null, 42]) { assert.equal(truthy(v), false, `expected falsy(${JSON.stringify(v)})`); } }); }); describe('SENSITIVE_PATH / GENERATED_PATH', () => { it('skips .env, .pem, id_rsa, secrets, credentials, .git', () => { for (const p of [ '/x/.env', '/x/.env.production', '/x/server.pem', '/x/id_rsa', '/x/id_rsa.pub', '/x/api-secret.json', '/x/client_secret.ts', '/x/credentials.yml', '/x/.git/config', ]) { assert.ok(SENSITIVE_PATH.test(p), `expected sensitive: ${p}`); } }); it('does not flag normal source files as sensitive', () => { for (const p of [ '/x/src/Card.tsx', '/x/app/page.html', '/x/styles/main.css', '/x/src/CredentialForm.tsx', '/x/src/SecretPage.jsx', '/x/src/secretary-dashboard.vue', '/x/src/credentials-panel.tsx', ]) { assert.ok(!SENSITIVE_PATH.test(p), `unexpected sensitive: ${p}`); } }); it('skips generated / lock / build output paths', () => { for (const p of [ '/x/src/foo.generated.tsx', '/x/types.d.ts', '/x/bundle.min.js', '/x/node_modules/lib/index.tsx', '/x/dist/Card.tsx', '/x/build/index.html', '/x/pkg.lock.json', '/x/.next/server.js', '/x/coverage/report.html', ]) { assert.ok(GENERATED_PATH.test(p), `expected generated: ${p}`); } }); it('skips committed build output living outside dist/', () => { // Not every generated artifact lands in dist/. Repos commit browser // bundles and detector copies next to source, and findings against them // are never actionable. for (const p of [ '/x/site/public/js/generated/counts.js', '/x/src/generated/schema.ts', '/x/app/generated/api.tsx', ]) { assert.ok(GENERATED_PATH.test(p), `expected generated: ${p}`); } }); it('does not treat authored paths that merely mention generation as generated', () => { for (const p of [ '/x/src/generateReport.ts', '/x/src/generated-utils.ts', '/x/src/components/CodeGenerator.tsx', '/x/src/ui/regenerate-button.jsx', ]) { assert.ok(!GENERATED_PATH.test(p), `unexpected generated: ${p}`); } }); }); describe('isScanTargetInsideProject()', () => { let root; beforeEach(() => { root = mkTmp(); }); afterEach(() => fs.rmSync(root, { recursive: true, force: true })); it('accepts files under the project root and the root itself', () => { const file = path.join(root, 'src', 'Card.tsx'); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, 'noop'); assert.equal(isScanTargetInsideProject(file, root), true); assert.equal(isScanTargetInsideProject(root, root), true); }); it('rejects siblings, temp scratchpads, and empty inputs', () => { const scratch = mkTmp(); try { const outside = path.join(scratch, 'landing.html'); fs.writeFileSync(outside, '

x

'); assert.equal(isScanTargetInsideProject(outside, root), false); assert.equal(isScanTargetInsideProject('', root), false); assert.equal(isScanTargetInsideProject(outside, ''), false); } finally { fs.rmSync(scratch, { recursive: true, force: true }); } }); it('treats symlinked and canonical forms of the same tree as one project', () => { const real = path.join(root, 'real'); const link = path.join(root, 'link'); fs.mkdirSync(path.join(real, 'src'), { recursive: true }); fs.symlinkSync(real, link); const file = path.join(real, 'src', 'Card.tsx'); fs.writeFileSync(file, 'noop'); assert.equal(isScanTargetInsideProject(file, link), true); assert.equal(isScanTargetInsideProject(path.join(link, 'src', 'Card.tsx'), real), true); }); it('classifies not-yet-written files by their nearest existing ancestor', () => { // The before-edit hook gates proposed Writes, so the target often does // not exist. Canonicalization must climb to an existing ancestor rather // than bail, or a new file under a symlinked root would read as outside. const real = path.join(root, 'real'); const link = path.join(root, 'link'); fs.mkdirSync(real, { recursive: true }); fs.symlinkSync(real, link); assert.equal(isScanTargetInsideProject(path.join(link, 'src', 'New.tsx'), real), true); assert.equal(isScanTargetInsideProject(path.join(real, 'deep', 'New.tsx'), link), true); assert.equal(isScanTargetInsideProject(path.join(root, 'elsewhere', 'New.tsx'), real), false); }); }); describe('readConfig()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('returns defaults when file missing', () => { const cfg = readConfig(cwd); assert.equal(cfg.enabled, true); assert.equal(cfg.limits.maxFindings, DEFAULT_CONFIG.limits.maxFindings); }); it('parses hook runtime and legacy hook detector filters', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false, ignoreRules: ['side-tab'], ignoreFiles: ['src/legacy/**'], minSeverity: 'error', limits: { maxFindings: 2, maxChars: 1000 }, }, })); const cfg = readConfig(cwd); assert.equal(cfg.enabled, false); assert.deepEqual(cfg.ignoreRules, ['side-tab']); assert.deepEqual(cfg.ignoreFiles, ['src/legacy/**']); assert.deepEqual(cfg.ignoreValues, []); assert.equal(cfg.limits.maxFindings, 2); assert.equal(cfg.limits.maxChars, 1000); }); it('merges shared config first and local config second', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false, minSeverity: 'error', limits: { maxFindings: 2, maxChars: 1000 }, }, detector: { ignoreRules: ['side-tab'], ignoreFiles: ['src/legacy/**'], ignoreValues: [ { rule: 'overused-font', value: 'inter', reason: 'team default' }, ], }, })); fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { enabled: true, minSeverity: 'warning', limits: { maxFindings: 4 }, }, detector: { ignoreRules: ['gradient-text', 'side-tab'], ignoreFiles: ['src/local/**'], ignoreValues: [ { rule: 'overused-font', value: 'Roboto' }, { rule: 'overused-font', value: 'Inter', reason: 'local override' }, ], }, })); const cfg = readConfig(cwd); assert.equal(cfg.enabled, true); assert.deepEqual(cfg.ignoreRules, ['side-tab', 'gradient-text']); assert.deepEqual(cfg.ignoreFiles, ['src/legacy/**', 'src/local/**']); assert.deepEqual(cfg.ignoreValues, [ { rule: 'overused-font', value: 'inter', reason: 'local override' }, { rule: 'overused-font', value: 'roboto' }, ]); assert.equal(cfg.limits.maxFindings, 4); assert.equal(cfg.limits.maxChars, 1000); }); it('tolerates malformed JSON and falls back to defaults', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), '{ not json'); const cfg = readConfig(cwd); assert.equal(cfg.enabled, true); }); it('ignores malformed local config while preserving valid shared config', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false, ignoreRules: ['side-tab'], limits: { maxFindings: 3 }, }, })); fs.writeFileSync(getLocalConfigPath(cwd), '{ not json'); const cfg = readConfig(cwd); assert.equal(cfg.enabled, false); assert.deepEqual(cfg.ignoreRules, ['side-tab']); assert.equal(cfg.limits.maxFindings, 3); }); it('parses detector.extensions entries and defaults engine to html', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { extensions: [ { ext: '.blade.php' }, { ext: '.html.erb', engine: 'html' }, { ext: '.d.ts.hbs', engine: 'text' }, 'twig', { ext: '' }, { engine: 'html' }, 42, ], }, })); const cfg = readConfig(cwd); assert.deepEqual(cfg.extensions, [ { ext: '.blade.php', engine: 'html' }, { ext: '.html.erb', engine: 'html' }, { ext: '.d.ts.hbs', engine: 'text' }, { ext: '.twig', engine: 'html' }, ]); }); it('lets local-config detector.extensions override the shared engine per ext', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { extensions: [{ ext: '.blade.php', engine: 'html' }] }, })); fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ detector: { extensions: [{ ext: '.blade.php', engine: 'text' }, { ext: '.twig' }] }, })); const cfg = readConfig(cwd); assert.deepEqual(cfg.extensions, [ { ext: '.blade.php', engine: 'text' }, { ext: '.twig', engine: 'html' }, ]); }); it('defaults detector.extensions to an empty list', () => { assert.deepEqual(readConfig(cwd).extensions, []); }); it('parses the new quiet and auditLog fields from the unified config', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true, auditLog: '~/hook.ndjson' }, detector: { designSystem: { enabled: false } }, })); const cfg = readConfig(cwd); assert.equal(cfg.quiet, true); assert.equal(cfg.auditLog, '~/hook.ndjson'); assert.deepEqual(cfg.designSystem, { enabled: false }); }); }); describe('readCache / persistCache / bumpEditCount', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('round-trips a session', () => { const cache = readCache(cwd); bumpEditCount(cache, 'sid-1', '/x/a.tsx'); bumpEditCount(cache, 'sid-1', '/x/a.tsx'); rememberFindings(cache, 'sid-1', '/x/a.tsx', [finding('side-tab', 12)]); persistCache(cwd, cache); const reloaded = readCache(cwd); const file = reloaded.sessions['sid-1'].files['/x/a.tsx']; assert.equal(file.editCount, 2); assert.ok(file.findings.includes('side-tab:12')); }); it('keeps same-line value-specific findings distinct in the cache', () => { const cache = readCache(cwd); const hotPink = { ...finding('design-system-color', 7, { snippet: 'Undocumented color #ff00aa' }), ignoreValue: '#ff00aa', }; const cyan = { ...finding('design-system-color', 7, { snippet: 'Undocumented color rgb(20, 180, 220)' }), ignoreValue: 'rgb(20, 180, 220)', }; assert.deepEqual(dedupeAgainstCache([hotPink, cyan], cache, 'sid-1', '/x/a.css'), [hotPink, cyan]); rememberFindings(cache, 'sid-1', '/x/a.css', [hotPink]); assert.deepEqual(dedupeAgainstCache([hotPink, cyan], cache, 'sid-1', '/x/a.css'), [cyan]); }); it('garbage-collects oldest sessions over CACHE_MAX_SESSIONS', () => { const cache = readCache(cwd); // Stamp 10 sessions, each with a unique updatedAt so ordering is stable. for (let i = 0; i < 10; i++) { const id = `sid-${i}`; cache.sessions[id] = { updatedAt: 1000 + i, files: {} }; } persistCache(cwd, cache); const reloaded = readCache(cwd); assert.equal(Object.keys(reloaded.sessions).length, 8); assert.ok(reloaded.sessions['sid-9'], 'newest preserved'); assert.ok(!reloaded.sessions['sid-0'], 'oldest gc-ed'); }); }); describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { let cwd; let cacheRoot; let savedEnv; beforeEach(() => { cwd = mkTmp(); cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cache-root-')); savedEnv = process.env.IMPECCABLE_CACHE_ROOT; }); afterEach(() => { if (savedEnv === undefined) delete process.env.IMPECCABLE_CACHE_ROOT; else process.env.IMPECCABLE_CACHE_ROOT = savedEnv; fs.rmSync(cwd, { recursive: true, force: true }); fs.rmSync(cacheRoot, { recursive: true, force: true }); }); it('keeps hook state project-local when the env var is unset', () => { delete process.env.IMPECCABLE_CACHE_ROOT; assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); assert.equal(getPendingPath(cwd), path.join(cwd, '.impeccable', 'hook.pending.json')); }); it('treats a blank env var as unset', () => { process.env.IMPECCABLE_CACHE_ROOT = ' '; assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); }); // Mirrors hookStateDir's slug formula: readable separator-mapped path plus // an 8-hex sha256 disambiguator. function slugFor(p) { const resolved = path.resolve(p); const readable = resolved.replace(/[:\\/.]/g, '-'); const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); return `${readable}-${digest}`; } it('relocates cache and pending under a per-project slug dir', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')); assert.equal(getPendingPath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.pending.json')); }); it('slug maps separators, colons, and dots to hyphens, with a digest suffix', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const proj = path.join(cwd, 'my.app', 'v2'); const slugDir = path.basename(path.dirname(getCachePath(proj))); assert.doesNotMatch(slugDir, /[:\\/.]/, 'no path-significant chars survive'); assert.match(slugDir, /my-app-v2-[0-9a-f]{8}$/, `readable slug + 8-hex digest (got ${slugDir})`); }); it('distinct projects whose readable slugs collide get distinct state dirs', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const dotted = path.join(cwd, 'my.app'); const dashed = path.join(cwd, 'my-app'); // Readable part is identical for both... assert.equal( path.resolve(dotted).replace(/[:\\/.]/g, '-'), path.resolve(dashed).replace(/[:\\/.]/g, '-'), ); // ...but the digest keeps their hook state apart. assert.notEqual(path.dirname(getCachePath(dotted)), path.dirname(getCachePath(dashed))); }); it('trailing separators and relative segments slug to the same dir', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const canonical = getCachePath(cwd); assert.equal(getCachePath(cwd + path.sep), canonical); assert.equal(getCachePath(path.join(cwd, 'sub', '..')), canonical); }); it('trims stray whitespace from the env value', () => { process.env.IMPECCABLE_CACHE_ROOT = ` ${cacheRoot} `; assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')); }); it('persistCache degrades gracefully when the cache root is unusable', () => { // Point the root at an existing FILE so mkdir of the slug dir must fail. const blocker = path.join(cacheRoot, 'not-a-dir'); fs.writeFileSync(blocker, 'x'); process.env.IMPECCABLE_CACHE_ROOT = blocker; const cache = readCache(cwd); bumpEditCount(cache, 'sid-1', '/x/a.tsx'); assert.equal(persistCache(cwd, cache), false, 'returns false instead of throwing'); assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false); }); it('config paths stay project-local even when the redirect is active', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; assert.equal(getConfigPath(cwd), path.join(cwd, '.impeccable', 'config.json')); assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json')); }); it('expands a leading ~/ against os.homedir()', () => { // Property check without duplicating the expansion: the tilde form must // resolve identically to the explicit homedir-joined form. process.env.IMPECCABLE_CACHE_ROOT = path.join(os.homedir(), 'impeccable-state'); const explicit = getCachePath(cwd); process.env.IMPECCABLE_CACHE_ROOT = '~/impeccable-state'; assert.equal(getCachePath(cwd), explicit); assert.ok(explicit.startsWith(os.homedir()), 'anchored under the home dir'); }); it('persistCache round-trips through the redirect dir and leaves the project root clean', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const cache = readCache(cwd); bumpEditCount(cache, 'sid-1', '/x/a.tsx'); assert.equal(persistCache(cwd, cache), true); assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root untouched'); assert.equal(fs.existsSync(path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')), true); const reloaded = readCache(cwd); assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1); }); function redirectEventFor(file, sessionId = 'redir-sid') { return { session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; } function writeProjectFile(rel, body) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } it('runHook end-to-end: findings persist under the redirect root, project root stays clean', async () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const file = writeProjectFile('src/Card.tsx', 'noop'); const det = fakeDetector([finding('text-overflow', 1)]); const first = await runHook({ stdinJson: JSON.stringify(redirectEventFor(file)), env: {}, cwd, detector: det, }); assert.match(first.stdout, /Design hook findings requiring review/); assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'no project-local footprint'); assert.equal(fs.existsSync(getCachePath(cwd)), true, 'cache lands under the redirect root'); // Session dedup still works across runs through the redirected cache. const second = await runHook({ stdinJson: JSON.stringify(redirectEventFor(file)), env: {}, cwd, detector: det, }); assert.doesNotMatch(second.stdout, /Design hook findings requiring review/); assert.match(second.stdout, /flagged earlier this session/); }); it('runHook end-to-end: clean edits keep persisting editCount once redirected state exists', async () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const file = writeProjectFile('src/Card.tsx', 'noop'); // Earn the footprint (in the redirect dir) with a real finding first. await runHook({ stdinJson: JSON.stringify(redirectEventFor(file)), env: {}, cwd, detector: fakeDetector([finding('text-overflow', 1)]), }); assert.equal(fs.existsSync(getCachePath(cwd)), true); // A clean follow-up edit must still persist its editCount bump — the // opted-in check has to see the redirected cache, not just `/.impeccable/`. await runHook({ stdinJson: JSON.stringify(redirectEventFor(file)), env: {}, cwd, detector: fakeDetector([]), }); const cache = readCache(cwd); assert.equal(cache.sessions['redir-sid'].files[file].editCount, 2); assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root still clean'); }); it('runHook end-to-end: a no-footprint clean edit writes nothing anywhere (gates hold under redirect)', async () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const file = writeProjectFile('src/Card.tsx', 'noop'); const r = await runHook({ stdinJson: JSON.stringify(redirectEventFor(file)), env: {}, cwd, detector: fakeDetector([]), }); assert.match(r.stdout, /No deterministic design-quality issues found/); assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false); assert.equal(fs.existsSync(getCachePath(cwd)), false, 'redirect root also stays empty'); }); }); describe('ensureHookGitExcludes()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('adds hook runtime files to local git info exclude, not tracked .gitignore', () => { execFileSync('git', ['init', '-q'], { cwd }); const result = ensureHookGitExcludes(cwd); assert.equal(result.mode, 'git-info-exclude'); assert.equal(result.changed, true); assert.equal(fs.existsSync(path.join(cwd, '.gitignore')), false); const exclude = fs.readFileSync(path.join(cwd, '.git', 'info', 'exclude'), 'utf-8'); assert.match(exclude, /\.impeccable\/hook\.cache\.json/); assert.match(exclude, /\.impeccable\/hook\.pending\.json/); assert.match(exclude, /\.impeccable\/config\.local\.json/); const second = ensureHookGitExcludes(cwd); assert.equal(second.changed, false); const rewritten = fs.readFileSync(path.join(cwd, '.git', 'info', 'exclude'), 'utf-8'); assert.equal((rewritten.match(/impeccable-hook-ignore-start/g) || []).length, 1); }); }); describe('matchesAnyGlob()', () => { it('handles `**`, `*`, basename, and `{}` alternation', () => { assert.ok(matchesAnyGlob('src/legacy/Foo.tsx', ['src/legacy/**'])); assert.ok(matchesAnyGlob('src/Foo.generated.tsx', ['**/*.generated.tsx'])); assert.ok(matchesAnyGlob('src/Foo.generated.tsx', ['*.generated.tsx'])); // {ts,tsx} expands to (?:ts|tsx) so the actual file path is what matches. assert.ok(matchesAnyGlob('src/widget/Foo.tsx', ['src/widget/Foo.{ts,tsx}'])); assert.ok(matchesAnyGlob('src/widget/Foo.ts', ['src/widget/Foo.{ts,tsx}'])); assert.ok(!matchesAnyGlob('src/widgets/Foo.tsx', ['src/legacy/**'])); assert.ok(!matchesAnyGlob('src/Foo.tsx', [])); }); }); describe('filterFindings()', () => { it('drops by ignoreRules and ignores legacy minSeverity config', () => { const content = [ 'a', // line 1 'b', // line 2 ].join('\n'); const findings = [ finding('side-tab', 1, { severity: 'warning' }), finding('gradient-text', 2, { severity: 'warning' }), finding('overused-font', 5, { severity: 'advisory' }), ]; const filtered = filterFindings(findings, content, '.ts', { ignoreRules: ['side-tab'], minSeverity: 'error', advisoryRules: 'include', limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']); }); it('drops advisory-rule findings by default', () => { const findings = [ finding('side-tab', 1), finding('em-dash-overuse', 2), finding('design-system-radius', 3, { severity: 'advisory' }), finding('gradient-text', 3), ]; const filtered = filterFindings(findings, '', '.html', { ignoreRules: [], limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => f.antipattern), ['side-tab', 'gradient-text']); }); it('keeps advisory-rule findings when advisoryRules is "include"', () => { const findings = [ finding('side-tab', 1), finding('em-dash-overuse', 2), ]; const filtered = filterFindings(findings, '', '.html', { ignoreRules: [], advisoryRules: 'include', limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => f.antipattern), ['side-tab', 'em-dash-overuse']); }); it('recognizes advisory findings by rule id or explicit flag', () => { assert.ok(ADVISORY_RULES.has('em-dash-overuse')); assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true); assert.equal(isAdvisoryFinding({ antipattern: 'anything', advisory: true }), true); assert.equal(isAdvisoryFinding({ antipattern: 'anything', severity: 'advisory' }), true); assert.equal(isAdvisoryFinding(finding('side-tab', 1)), false); }); it('does not treat source comments as hook suppression', () => { const content = [ '/* impeccable: ignore * */', '.card { font-family: "Roboto", sans-serif; }', '', '
Card
', ].join('\n'); const filtered = filterFindings( [finding('overused-font', 2), finding('side-tab', 4)], content, '.html', { ignoreRules: [], minSeverity: 'warning', limits: DEFAULT_CONFIG.limits } ); assert.deepEqual(filtered.map((f) => f.antipattern), ['overused-font', 'side-tab']); }); it('drops only matching rule/value pairs from ignoreValues', () => { const findings = [ finding('overused-font', 1, { snippet: 'Primary font: Inter (86% of text)' }), finding('overused-font', 2, { snippet: 'Primary font: Roboto' }), finding('overused-font', 5, { snippet: 'Google Fonts: space grotesk' }), 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' }, { rule: 'overused-font', value: 'space grotesk' }, { rule: 'bounce-easing', value: 'bounce-ball' }, ], minSeverity: 'warning', limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['overused-font:2', 'bounce-easing:4', 'side-tab:3']); }); it('honors a specific-value ignoreValues entry for design-system-font-size', () => { // The rule carries an ignoreValue and the hook's own directive tells the // agent to waive value-specific findings with `hooks ignore-value`, but // font-size was missing from the direct-value rule set, so any waiver // naming an actual size was filtered against an empty extracted value and // silently did nothing. Only the `*` wildcard worked. const findings = [ { ...finding('design-system-font-size', 1), ignoreValue: '0.82rem' }, { ...finding('design-system-font-size', 2), ignoreValue: '0.9rem' }, ]; const filtered = filterFindings(findings, '', '.css', { ignoreRules: [], ignoreValues: [{ rule: 'design-system-font-size', value: '0.82rem' }], limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => f.ignoreValue), ['0.9rem']); }); it('scopes ignoreValues to file globs when files are provided', () => { const findings = [ { ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: '#8b5cf6' }, { ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/feature.css' }), ignoreValue: '#8b5cf6' }, { ...finding('design-system-color', 3, { file: '/tmp/project/site/styles/home-kinpaku.css' }), ignoreValue: 'oklch(60% 0.25 350 / 0.22)' }, ]; const filtered = filterFindings(findings, '', '.css', { ignoreRules: [], ignoreValues: [ { rule: 'design-system-color', value: '#8b5cf6', files: ['site/styles/main.css'] }, { rule: 'design-system-color', value: 'oklch(60% 0.25 350 / 0.22)', file: 'site/styles/home-kinpaku.css' }, ], limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => `${f.file}:${f.line}`), ['/tmp/project/site/styles/feature.css:2']); }); it('matches equivalent design-system color ignore values', () => { const findings = [ { ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/rgb.css' }), ignoreValue: 'rgb(139, 92, 246)' }, { ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/hex.css' }), ignoreValue: '#8b5cf6' }, { ...finding('design-system-color', 3, { file: '/tmp/project/site/styles/alpha.css' }), ignoreValue: 'rgba(139, 92, 246, 0.5)' }, { ...finding('design-system-color', 4, { file: '/tmp/project/site/styles/other.css' }), ignoreValue: '#8b5cf7' }, { ...finding('design-system-radius', 5, { file: '/tmp/project/site/styles/radius.css' }), ignoreValue: 'rgb(139, 92, 246)' }, ]; const filtered = filterFindings(findings, '', '.css', { ignoreRules: [], ignoreValues: [ { rule: 'design-system-color', value: '#8b5cf6' }, { rule: 'design-system-color', value: 'rgb(139 92 246 / 100%)' }, ], limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), [ 'design-system-color:3', 'design-system-color:4', 'design-system-radius:5', ]); }); it('allows wildcard ignoreValues only when scoped to files', () => { const findings = [ { ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: '#8b5cf6' }, { ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/feature.css' }), ignoreValue: '#8b5cf6' }, { ...finding('design-system-font', 3, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: 'Inter' }, ]; const filtered = filterFindings(findings, '', '.css', { ignoreRules: [], ignoreValues: [ { rule: 'design-system-color', value: '*', files: ['site/styles/main.css'] }, { rule: 'design-system-font', value: '*' }, ], limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['design-system-color:2', 'design-system-font:3']); }); it('extracts overused-font values from primary, CSS, and Google font snippets', () => { assert.equal( extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'Primary font: Open Sans (80% of text)' })), 'open sans', ); assert.equal( extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'body { font-family: "Inter", sans-serif; }' })), 'inter', ); assert.equal( extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400' })), 'plus jakarta sans', ); assert.equal( extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'Google Fonts: space grotesk' })), 'space grotesk', ); 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', () => { let cwd; const script = path.resolve('skill', 'scripts', 'hook-admin.mjs'); beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function runAdmin(args) { return execFileSync(process.execPath, [script, ...args], { cwd, env: { ...process.env }, encoding: 'utf-8', }); } it('refuses an empty --file glob instead of silently writing a project-wide ignore', () => { // `--file=` was dropped by filter(Boolean), so this reported success and // stored an entry with no files: a broader suppression than was asked for. for (const args of [['--file='], ['--file', ''], ['--files=']]) { assert.throws( () => runAdmin(['ignore-value', 'overused-font', 'Inter', ...args]), /requires a non-empty glob/, `empty glob via ${args.join(' ')} must error`, ); } // `--file --reason "why"` consumed --reason as the scope and let the reason // text fold into the value: stored value="* why" files=["--reason"], success. assert.throws( () => runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', '--reason', 'why']), /requires a glob, got the flag --reason/, 'a following flag is not a glob', ); assert.equal(fs.existsSync(path.join(cwd, '.impeccable', 'config.json')), false, 'nothing may be written'); }); it('matches an on-disk scope whose glob order differs from the sorted argv form', () => { // Storage is canonical now, but configs written before that are not. Every key // that hashes `files` must sort or a re-add duplicates the entry. fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({ detector: { ignoreValues: [ { rule: 'design-system-font-size', value: '*', files: ['b.css', 'a.css'], createdAt: '2026-01-01T00:00:00.000Z' }, ] }, })); runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'a.css', '--file', 'b.css', '--reason', 're-add']); const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'config.json'), 'utf-8')); const entries = cfg.detector.ignoreValues.filter((e) => e.rule === 'design-system-font-size'); assert.equal(entries.length, 1, 'an unsorted on-disk scope must match the sorted argv form, not duplicate'); assert.equal(entries[0].reason, 're-add', 'the existing entry is the one updated'); }); it('stores a multi-file scope in canonical order so argv order cannot duplicate it', () => { runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'b.css', '--file', 'a.css']); runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'a.css', '--file', 'b.css']); const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'config.json'), 'utf-8')); const entries = cfg.detector.ignoreValues.filter((e) => e.rule === 'design-system-font-size'); assert.equal(entries.length, 1, 'the same scope in a different order is one entry, not two'); assert.deepEqual(entries[0].files, ['a.css', 'b.css']); }); it('status shows the file scope of a scoped wildcard ignore', () => { runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'src/widget.js']); const out = runAdmin(['status']); // Printing `design-system-font-size=*` bare reads as the project-wide // wildcard this command refuses, which is the opposite of what is on disk. assert.match(out, /design-system-font-size=\*\s*\[src\/widget\.js\]/); }); it('refuses a bare wildcard and names a project-wide command that actually works', () => { assert.throws( () => runAdmin(['ignore-value', 'design-system-font-size', '*']), (err) => /--file/.test(String(err.stderr)) && /ignore-rule design-system-font-size\./.test(String(err.stderr)), ); // ignore-rule overused-font refuses on its own without --all-values, so the // suggestion must carry the flag or it hands the user a second error. assert.throws( () => runAdmin(['ignore-value', 'overused-font', '*']), (err) => /ignore-rule overused-font --all-values/.test(String(err.stderr)), ); }); it('ignore-value writes shared config by default without creating local config', () => { const out = runAdmin(['ignore-value', 'overused-font', 'Inter', '--reason', 'User confirmed Inter']); assert.match(out, /overused-font=inter/); assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false); const raw = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')); assert.equal(raw.hook, undefined); const shared = raw.detector; assert.deepEqual(shared.ignoreRules, []); assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [ { rule: 'overused-font', value: 'inter', reason: 'User confirmed Inter' }, ]); assert.match(shared.ignoreValues[0].createdAt, /^\d{4}-\d{2}-\d{2}T/); }); it('ignore-value --shared remains accepted for shared config', () => { runAdmin(['ignore-value', 'overused-font', 'Open', 'Sans', '--shared', '--reason', 'Brand font']); assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [ { rule: 'overused-font', value: 'open sans', reason: 'Brand font' }, ]); }); it('ignore-value --local writes private config and status reports local ignores', () => { runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']); runAdmin(['ignore-value', 'OVERUSED-FONT', '"Inter"', '--local', '--reason', 'Still intentional']); assert.equal(fs.existsSync(getConfigPath(cwd)), false); const raw = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')); assert.equal(raw.hook, undefined); const local = raw.detector; assert.equal(local.designSystem, undefined, 'local ignore should not override shared design-system state'); assert.equal(local.ignoreValues.length, 1); assert.equal(local.ignoreValues[0].reason, 'Still intentional'); const status = runAdmin(['status']); assert.match(status, /local file:\s+\.impeccable\/config\.local\.json/); assert.match(status, /ignoreValues:\s+overused-font=inter/); }); // detector.ignoreValues honours a `files` scope, which is the narrowest way to // silence one noisy rule on one file. hook-admin could not write it, so the // only reachable option was ignore-file, which silences every rule for that // file forever. it('ignore-value scopes a wildcard to files via --file', () => { const out = runAdmin([ 'ignore-value', 'design-system-font-size', '*', '--file', 'src/overlay/widget.js', '--reason', 'Widget builds its own type scale', ]); assert.match(out, /scoped to src\/overlay\/widget\.js/); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreValues, [{ rule: 'design-system-font-size', value: '*', files: ['src/overlay/widget.js'], createdAt: shared.ignoreValues[0].createdAt, reason: 'Widget builds its own type scale', }]); }); it('ignore-value accepts --file=, --files= and repeated --file', () => { runAdmin(['ignore-value', 'side-tab', '*', '--file=a.css']); runAdmin(['ignore-value', 'side-tab', '*', '--files=b.css']); runAdmin(['ignore-value', 'low-contrast', '*', '--file', 'c.css', '--file', 'd.css']); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual( shared.ignoreValues.map(({ rule, files }) => ({ rule, files })), [ { rule: 'side-tab', files: ['a.css'] }, { rule: 'side-tab', files: ['b.css'] }, { rule: 'low-contrast', files: ['c.css', 'd.css'] }, ], 'each distinct file scope is its own entry; a rule+value-only key overwrote them', ); }); it('ignore-value refuses a wildcard with no file scope', () => { assert.throws( () => runAdmin(['ignore-value', 'design-system-font-size', '*']), /Wildcard value ignores must be scoped with --file/, 'a bare wildcard is ignore-rule\'s job, not a per-file waiver', ); assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config'); }); it('ignore-value refuses exact values for rules that cannot extract one', () => { assert.throws( () => runAdmin(['ignore-value', 'cramped-padding', 'padding: 4px 8px']), /cramped-padding has no extractable ignore value.*ignore-value cramped-padding "\*" --file /, ); assert.throws( () => runAdmin(['ignore-value', 'side-tab', 'Inter', '--file', 'a.css']), /side-tab has no extractable ignore value.*ignore-value side-tab "\*" --file /, ); assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config'); const out = runAdmin(['ignore-value', 'overused-font', 'Inter']); assert.match(out, /Added overused-font=inter/); runAdmin(['ignore-value', 'cramped-padding', '*', '--file', 'index.html']); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.equal(shared.ignoreValues.filter((e) => e.rule === 'cramped-padding').length, 1); const entry = shared.ignoreValues.find((e) => e.rule === 'cramped-padding'); assert.equal(entry.value, '*'); assert.deepEqual(entry.files, ['index.html']); }); it('ignore-value --file requires a glob', () => { assert.throws( () => runAdmin(['ignore-value', 'side-tab', '*', '--file']), /--file requires a glob/, ); }); it('ignore-value rejects an unknown flag instead of folding it into the value', () => { // `--shard` (a typo for --shared) used to store the value "inter --shard", // which matches nothing, while reporting a successful suppression. assert.throws( () => runAdmin(['ignore-value', 'overused-font', 'Inter', '--shard']), /Unknown ignore-value flag: --shard/, ); assert.equal(fs.existsSync(getConfigPath(cwd)), false); }); // Every write runs the entries through normalizeIgnoreValueEntries. Emitting a // different key order than the one on disk rewrote all untouched entries. it('an unrelated edit leaves existing ignoreValues byte-identical', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); const seeded = { detector: { ignoreRules: [], ignoreFiles: [], ignoreValues: [ { rule: 'bounce-easing', value: 'bounce-ball', createdAt: '2026-06-15T04:15:03.164Z', reason: 'Intentional', }, { rule: 'design-system-color', value: '*', files: ['site/styles/demo.css'], createdAt: '2026-06-15T23:37:38.170Z', reason: 'Deliberate off-system demo', }, ], }, }; fs.writeFileSync(getConfigPath(cwd), JSON.stringify(seeded, null, 2) + '\n'); const before = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector.ignoreValues; runAdmin(['ignore-file', 'some/other/**']); const after = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(after.ignoreFiles, ['some/other/**'], 'the intended change still lands'); assert.equal( JSON.stringify(after.ignoreValues), JSON.stringify(before), 'untouched ignoreValues must keep their exact key order, or every config diff churns', ); }); // hook-lib.mjs (skill, ships into harness dirs) and cli/lib/impeccable-config.mjs // (CLI + Pages functions) carry independent copies of this normalizer by // necessity. They write the same file, so a key-order drift between them makes // the config churn depending on which tool touched it last. it('both config normalizers emit identical entries', () => { const input = [ { rule: 'BOUNCE-EASING', value: 'Bounce-Ball', reason: ' r ', createdAt: '2026-01-01T00:00:00.000Z' }, { rule: 'design-system-color', value: '*', files: [' a.css ', 'b.css', 'a.css'], createdAt: '2026-02-02T00:00:00.000Z' }, { rule: 'side-tab', value: '*', file: 'legacy.css' }, { rule: '', value: 'dropped' }, ]; assert.equal( JSON.stringify(normalizeIgnoreValueEntries(input)), JSON.stringify(normalizeIgnoreValueEntriesCli(input)), 'skill/scripts/hook-lib.mjs and cli/lib/impeccable-config.mjs must agree, key order included', ); // And pin the canonical order itself, which is what the config on disk uses. const full = { rule: 'side-tab', value: '*', files: ['a.css'], createdAt: '2026-01-01T00:00:00.000Z', reason: 'r' }; assert.deepEqual( Object.keys(normalizeIgnoreValueEntries([full])[0]), ['rule', 'value', 'files', 'createdAt', 'reason'], ); }); it('a /impeccable hooks edit preserves sibling hook fields (consent, quiet)', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); // A recorded per-developer consent in the local file... fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { consent: 'declined' } })); runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']); const localRaw = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')); assert.equal(localRaw.hook.consent, 'declined', 'consent must survive a local ignore-value edit'); assert.equal(localRaw.detector.ignoreValues.length, 1); // ...and a shared quiet flag survives an on/off toggle. fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true } })); runAdmin(['off']); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook; assert.equal(shared.enabled, false); assert.equal(shared.quiet, true, 'quiet must survive an enable/disable toggle'); }); it('hooks on accepts declined consent and installs missing provider manifests', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { consent: 'declined', quiet: true } })); for (const provider of ['.claude', '.agents', '.cursor', '.github']) { fs.mkdirSync(path.join(cwd, provider, 'skills', 'impeccable', 'scripts'), { recursive: true }); } fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true }); fs.writeFileSync(path.join(cwd, '.claude', 'settings.local.json'), JSON.stringify({ hooks: { PostToolUse: [ { matcher: 'OtherTool', hooks: [{ type: 'command', command: 'node "./local-hook.mjs"' }] }, { matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }, ], }, })); const out = runAdmin(['on']); assert.match(out, /Recorded local hook consent/); assert.match(out, /Installed or repaired hook manifests for: \.claude, \.agents, \.cursor, \.github/); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook; assert.equal(shared.enabled, true); const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook; assert.equal(local.consent, 'accepted'); assert.equal(local.quiet, true, 'unrelated local hook fields survive consent repair'); const claude = fs.readFileSync(path.join(cwd, '.claude', 'settings.local.json'), 'utf-8'); assert.match(claude, /local-hook\.mjs/); // One PostToolUse entry plus one Stop entry; the stale pre-existing // impeccable entry must have been stripped, not accumulated. assert.equal(claude.split('skills/impeccable/scripts/hook.mjs').length - 1, 2); assert.match(claude, /"Stop"/); const claudeManifest = JSON.parse(claude); const impeccableGroup = claudeManifest.hooks.PostToolUse.find((group) => group.hooks?.some((hook) => hook.command?.includes('skills/impeccable/scripts/hook.mjs'))); assert.ok(impeccableGroup, 'repaired Claude settings should contain the Impeccable PostToolUse group'); assert.equal(impeccableGroup.matcher, 'Edit|Write'); const codex = fs.readFileSync(path.join(cwd, '.codex', 'hooks.json'), 'utf-8'); assert.match(codex, /\.agents\/skills\/impeccable\/scripts\/hook\.mjs/); const cursor = fs.readFileSync(path.join(cwd, '.cursor', 'hooks.json'), 'utf-8'); assert.match(cursor, /\.cursor\/skills\/impeccable\/scripts\/hook-before-edit\.mjs/); const github = JSON.parse(fs.readFileSync(path.join(cwd, '.github', 'hooks', 'impeccable.json'), 'utf-8')); assert.equal(github.hooks.postToolUse[0].matcher, 'edit|create|apply_patch'); assert.match(github.hooks.postToolUse[0].bash, /\.github\/skills\/impeccable\/scripts\/hook\.mjs/); }); it('ignore-rule overused-font requires explicit broad suppression', () => { assert.throws( () => runAdmin(['ignore-rule', 'overused-font']), /ignore-value overused-font |--all-values/, ); assert.equal(fs.existsSync(getConfigPath(cwd)), false); }); it('ignore-rule overused-font --all-values writes a whole-rule suppression', () => { const out = runAdmin(['ignore-rule', 'overused-font', '--all-values', '--reason', 'User asked to ignore overused fonts generally']); assert.match(out, /Added "overused-font" to detector\.ignoreRules/); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreRules, ['overused-font']); assert.deepEqual(shared.ignoreValues, []); }); it('ignore-rule still allows non-value rules without --all-values', () => { runAdmin(['ignore-rule', 'side-tab']); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.ignoreRules, ['side-tab']); }); it('ignore-value rejects conflicting scope flags', () => { assert.throws( () => runAdmin(['ignore-value', 'overused-font', 'Inter', '--shared', '--local']), /Pass only one scope flag/, ); }); it('ignore-file --local writes only the private detector config', () => { const out = runAdmin(['ignore-file', '/abs/path/personal.html', '--local']); assert.equal(fs.existsSync(getConfigPath(cwd)), false); const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(local.ignoreFiles, ['/abs/path/personal.html']); assert.match(out, /local detector\.ignoreFiles/); }); it('ignore-file --local preserves the local advisory-rule preference', () => { fs.mkdirSync(path.dirname(getLocalConfigPath(cwd)), { recursive: true }); fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ detector: { advisoryRules: 'include' }, })); runAdmin(['ignore-file', '/abs/path/personal.html', '--local']); const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).detector; assert.equal(local.advisoryRules, 'include'); assert.deepEqual(local.ignoreFiles, ['/abs/path/personal.html']); }); for (const command of ['on', 'off']) { it(`hooks ${command} migrates a legacy hook advisory-rule preference`, () => { fs.mkdirSync(path.dirname(getConfigPath(cwd)), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { advisoryRules: 'include' }, })); runAdmin([command]); const config = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')); assert.equal(config.hook.advisoryRules, undefined); assert.equal(config.detector.advisoryRules, 'include'); }); } it('hooks on keeps the canonical advisory-rule preference during legacy migration', () => { fs.mkdirSync(path.dirname(getConfigPath(cwd)), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { advisoryRules: 'include' }, detector: { advisoryRules: 'exclude', extensions: [{ ext: '.blade.php', engine: 'html' }], }, })); runAdmin(['on']); const config = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')); assert.equal(config.hook.advisoryRules, undefined); assert.equal(config.detector.advisoryRules, 'exclude'); assert.deepEqual(config.detector.extensions, [{ ext: '.blade.php', engine: 'html' }]); }); it('ignore-file refuses unsupported reasons and unknown flags', () => { assert.throws( () => runAdmin(['ignore-file', 'src/legacy/**', '--reason', 'machine-local path']), /--reason is not supported for ignore-file/, ); assert.throws( () => runAdmin(['ignore-file', 'src/legacy/**', '--shard']), /Unknown ignore-file flag: --shard/, ); assert.equal(fs.existsSync(getConfigPath(cwd)), false); assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false); }); it('ignore-file writes shared config that suppresses a later hook run', async () => { const file = path.join(cwd, 'src/ConfirmedCard.html'); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, '
Card
'); fs.mkdirSync(path.dirname(getConfigPath(cwd)), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { extensions: [{ ext: '.blade.php', engine: 'html' }] }, })); runAdmin(['ignore-file', 'src/ConfirmedCard.html']); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; assert.deepEqual(shared.extensions, [{ ext: '.blade.php', engine: 'html' }]); assert.deepEqual(shared.ignoreFiles, ['src/ConfirmedCard.html']); const r = await runHook({ stdinJson: JSON.stringify({ session_id: 'confirmed-ignore-file', cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1)]), }); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'config-ignore-file'); }); it('reset prunes impeccable entries from an installed manifest, sibling entries survive', () => { // Deliberately no .claude/skills/ folder in this fixture: the prune must // not be gated on the skill install surviving (repairHookManifests() // gates on it; a reset mid-uninstall most needs the prune to run anyway). fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false } })); fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true }); fs.writeFileSync(path.join(cwd, '.claude', 'settings.local.json'), JSON.stringify({ description: 'Impeccable design detector', hooks: { PostToolUse: [ { matcher: 'OtherTool', hooks: [{ type: 'command', command: 'node "./local-hook.mjs"' }] }, { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }, ], Stop: [{ hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }], }, })); const out = runAdmin(['reset']); assert.match(out, /Reset design hook config and cache \(removed:/); assert.match(out, /Removed hook entries from: \.claude\./); const claude = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.local.json'), 'utf-8')); assert.equal(claude.hooks.PostToolUse.length, 1); assert.match(claude.hooks.PostToolUse[0].hooks[0].command, /local-hook\.mjs/); assert.equal(claude.hooks.Stop, undefined, 'the impeccable-only Stop array should be dropped entirely'); }); it('reset prunes all four provider manifests installed via `on`', () => { for (const provider of ['.claude', '.agents', '.cursor', '.github']) { fs.mkdirSync(path.join(cwd, provider, 'skills', 'impeccable', 'scripts'), { recursive: true }); } runAdmin(['on']); assert.match(fs.readFileSync(path.join(cwd, '.claude', 'settings.local.json'), 'utf-8'), /skills\/impeccable\/scripts\/hook\.mjs/); const out = runAdmin(['reset']); assert.match(out, /Reset design hook config and cache \(removed:/); assert.match(out, /Removed hook entries from: \.claude, \.agents, \.cursor, \.github\./); assert.equal(fs.existsSync(path.join(cwd, '.claude', 'settings.local.json')), false, 'nothing else was in the manifest, so it is removed entirely'); assert.equal(fs.existsSync(path.join(cwd, '.codex', 'hooks.json')), false); assert.equal(fs.existsSync(path.join(cwd, '.cursor', 'hooks.json')), false); assert.equal(fs.existsSync(path.join(cwd, '.github', 'hooks', 'impeccable.json')), false); }); it('reset never touches the shared/committed manifest, only the local one', () => { // .claude/settings.json is the team-shared, typically committed file; // `on` only ever reads it and never writes it, so reset honors the same // write-scope asymmetry. const shared = JSON.stringify({ hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }] }, }); fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true }); fs.writeFileSync(path.join(cwd, '.claude', 'settings.json'), shared); fs.writeFileSync(path.join(cwd, '.claude', 'settings.local.json'), shared); const out = runAdmin(['reset']); // No config existed, so the message carries only the prune half. assert.match(out, /Removed hook entries from: \.claude\./); assert.doesNotMatch(out, /Reset design hook config and cache/); assert.equal(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf-8'), shared, 'shared settings.json must survive reset untouched'); assert.equal(fs.existsSync(path.join(cwd, '.claude', 'settings.local.json')), false, 'the local settings.local.json is still pruned'); }); it('reset with no manifests installed keeps the original config-only message', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false } })); const out = runAdmin(['reset']); assert.match(out, /Reset design hook config and cache/); assert.doesNotMatch(out, /Removed hook entries from/); assert.equal(fs.existsSync(getConfigPath(cwd)), false); }); it('reset leaves a manifest with no impeccable marker byte-for-byte unchanged', () => { fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true }); const unrelated = JSON.stringify({ hooks: { PostToolUse: [{ matcher: 'OtherTool', hooks: [{ type: 'command', command: 'node "./unrelated.mjs"' }] }] }, }); fs.writeFileSync(path.join(cwd, '.claude', 'settings.local.json'), unrelated); const out = runAdmin(['reset']); assert.match(out, /Already at defaults/); assert.equal(fs.readFileSync(path.join(cwd, '.claude', 'settings.local.json'), 'utf-8'), unrelated); }); it('on, off, then reset leaves nothing armed: config gone and every manifest unwired (issue #512 repro)', () => { fs.mkdirSync(path.join(cwd, '.claude', 'skills', 'impeccable', 'scripts'), { recursive: true }); runAdmin(['on']); runAdmin(['off']); runAdmin(['reset']); // The harness only invokes the hook through a manifest entry, so with the // config and every manifest gone the project cannot re-arm, whatever the // config default says. assert.equal(fs.existsSync(getConfigPath(cwd)), false); assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false); assert.equal(fs.existsSync(path.join(cwd, '.claude', 'settings.local.json')), false); }); }); describe('renderTemplate()', () => { it('starts with the versioned envelope and caps to maxFindings', () => { 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} Design hook findings requiring review in Card.tsx (12 issue(s)):`)); assert.match(text, /\.\.\. and 7 more \(see \/impeccable audit\)\./); // Exactly 5 finding lines. The footer's triage bullets also start with // "- ", so count only lines carrying a rule id. const lines = text.split('\n').filter((l) => /^- L\d+ \[/.test(l)); assert.equal(lines.length, 5); assert.ok(text.length <= DEFAULT_CONFIG.limits.maxChars); }); it('emits a directive footer (triage branches + executable self-serve ignore + honest provenance)', () => { // Steers the model: imperative triage into fix / suppress-and-disclose / // ask, a runnable hook-admin.mjs path for the self-served ignore, and the // provenance rule for --reason. 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, /Triage each finding/); assert.match(text, /what you fixed, what you suppressed, and what you left standing/); assert.match(text, /Real design problem: fix it\. Keep intentional design as designed\./); assert.match(text, /Confident false positive or sanctioned exception/); assert.match(text, /literal or domain-appropriate motion/); assert.match(text, /persist the narrowest ignore yourself and disclose it/); // quoteCommandArg quotes the hook-admin.mjs path per platform (single // quotes on POSIX, double on Windows; #533), so match either close quote. assert.match(text, /hook-admin\.mjs['"] ignore-value "" --reason ""/); assert.match(text, /Write "user confirmed" in a reason only when the user did/); assert.match(text, /Unsure: leave it as is and ask the user in one line/); assert.match(text, /Self-serve ends at ignore-value/); assert.match(text, /never add an ignore to push a blocked write through/); assert.match(text, /Full suppression ladder: \/impeccable hooks/); }); it('renders the one-line short footer when opts.footer is "short"', () => { const text = renderTemplate( [finding('side-tab', 1, { name: 'X' })], '/x/Card.tsx', DEFAULT_CONFIG, { cwd: '/x', footer: 'short' } ); assert.match(text, /Triage per the session policy/); // The short form names the tool without the absolute path; the runnable // invocation lives only in the session's first (full) footer. The quoted // path would render as `node '...'` on POSIX or `node "..."` on Windows, // so reject both. assert.match(text, /`hook-admin\.mjs ignore-value`/); assert.doesNotMatch(text, /node ['"]/); assert.match(text, /unsure, ask in one line/); assert.doesNotMatch(text, /Triage each finding/); assert.doesNotMatch(text, /Self-serve ends at ignore-value/); }); it('dedupes rule descriptions within one emission, keeping per-line ignore hints', () => { const desc = 'Long registry description that should appear once.'; const text = renderTemplate( [ finding('overused-font', 2, { name: 'Overused font', description: desc, snippet: 'font-family: "Roboto"' }), finding('overused-font', 9, { name: 'Overused font', description: desc, snippet: 'font-family: "Inter"' }), ], '/x/fonts.css', DEFAULT_CONFIG, { cwd: '/x' } ); const occurrences = text.split(desc).length - 1; assert.equal(occurrences, 1); // The repeat keeps the rule id, name, and its own value-specific hint. assert.match(text, /- L9 \[overused-font\] Overused font\. If intentional: `ignore-value overused-font Inter`\./); assert.match(text, /`ignore-value overused-font Roboto`/); }); it('shows the value-specific ignore hint for overused-font findings', () => { const text = renderTemplate( [finding('overused-font', 1, { name: 'Overused font', snippet: 'body { font-family: "Roboto", sans-serif; }' })], '/x/fonts.css', DEFAULT_CONFIG, { cwd: '/x' } ); // The line carries just the rule/value pair; the runnable hook-admin.mjs // prefix and the --reason contract are stated once in the footer. assert.match(text, /If intentional: `ignore-value overused-font Roboto`\./); }); it('shows the value-specific ignore hint 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, /If intentional: `ignore-value bounce-easing bounce-ball`\./); }); it('single-quotes a hostile font value so the suggestion cannot inject a shell command (#476)', () => { // The suggested pair comes straight from scanned file content. A // double-quoted arg would leave $(...) live for whoever pastes it into // the footer's hook-admin.mjs command; single quotes neutralize it. The // hint format is now the bare `ignore-value ''` pair (the // runnable command prefix, --shared/--reason contract live in the // footer), but the value still goes through quoteCommandArg. const text = renderTemplate( [finding('overused-font', 1, { name: 'Overused font', snippet: 'body { font-family: "$(touch pwned)", sans-serif; }', })], '/x/fonts.css', DEFAULT_CONFIG, { cwd: '/x' } ); assert.match(text, /ignore-value overused-font '\$\(touch pwned\)'/); assert.doesNotMatch(text, /ignore-value overused-font "\$\(touch pwned\)"/); }); it('quotes a hint value per platform: single quotes on POSIX, double quotes on Windows (#533)', () => { // #533 originally targeted the footer's concrete `--file ` // suggestion; directiveFooter() now carries only literal placeholders // (`--file `), so that surface is gone. The quoting-sensitive // surface that remains user-visible is the per-finding ignore hint, // whose value comes straight from scanned file content and is meant to // be pasted into the footer's command on this same machine. POSIX needs // single quotes so $(...) cannot execute; Windows cmd.exe treats single // quotes as literal, so a value with spaces must stay double-quoted or // the ignore scope is split at the space. const original = process.platform; const renderFor = (platform) => { Object.defineProperty(process, 'platform', { value: platform, configurable: true }); try { return renderTemplate( [finding('overused-font', 1, { name: 'Overused font', snippet: 'h1 { font-family: "Space Grotesk Var", sans-serif; }', })], '/x/fonts.css', DEFAULT_CONFIG, { cwd: '/x' } ); } finally { Object.defineProperty(process, 'platform', { value: original, configurable: true }); } }; assert.match(renderFor('linux'), /ignore-value overused-font 'Space Grotesk Var'/); assert.match(renderFor('win32'), /ignore-value overused-font "Space Grotesk Var"/); }); it('keeps the policy footer when reserveChars presses against the 500-char floor', () => { // Bugbot on PR #508: the note reservation used to be subtracted after // the 500-char floor, so the clamp could run at ~366 chars, below the // budget clampLastLine assumes safe, and the hard tail slice cut the // policy footer. The reserve now comes off before the floor; when the // floor wins, the note defers instead. const config = { ...DEFAULT_CONFIG, limits: { ...DEFAULT_CONFIG.limits, maxChars: 500 } }; const longPath = `/x/${'deeply-nested/'.repeat(6)}Component.tsx`; const text = renderTemplate( Array.from({ length: 6 }, (_, i) => finding('side-tab', i + 1, { name: 'Side tab', description: 'Colored side border.' })), longPath, config, { cwd: '/x', reserveChars: 134 } ); assert.ok(text.length <= 500, `stays inside the floored budget (got ${text.length})`); assert.match(text, /unsure, ask in one line\.$/); assert.doesNotMatch(text, /…$/); }); it('drops the L prefix when line is 0', () => { const text = renderTemplate( [finding('side-tab', 0, { name: 'X' })], '/x/a.tsx', DEFAULT_CONFIG, { cwd: '/x' } ); assert.match(text, /^- \[side-tab\]/m); }); it('does not suggest ignore-value for rules that cannot be value-filtered', () => { const text = renderTemplate( [finding('side-tab', 1, { name: 'Side tab', ignoreValue: 'Inter', })], '/x/a.tsx', DEFAULT_CONFIG, { cwd: '/x' } ); assert.doesNotMatch(text, /\/impeccable hooks ignore-value side-tab Inter/); }); it('clamps oversize output to maxChars', () => { const huge = Array.from({ length: 5 }, (_, i) => finding('side-tab', i + 1, { name: 'X', description: 'y'.repeat(2000) })); const text = renderTemplate(huge, '/x/a.tsx', { ...DEFAULT_CONFIG, limits: { maxFindings: 5, maxChars: 500 } }, { cwd: '/x' }); assert.ok(text.length <= 500); }); it('keeps a policy footer when the clamp cuts down to one finding line', () => { // At the minimum budget the full footer cannot fit beside a long finding, // so the clamp clips the finding line and downgrades to the short policy // instead of slicing the footer off the tail. const huge = [finding('side-tab', 1, { name: 'X', description: 'y'.repeat(2000) })]; const text = renderTemplate(huge, '/x/a.tsx', { ...DEFAULT_CONFIG, limits: { maxFindings: 5, maxChars: 500 } }, { cwd: '/x' }); assert.ok(text.length <= 500); assert.match(text, /\[side-tab\]/, 'the finding is still identified'); assert.match(text, /Triage per the session policy/, 'a clamped emission still carries the policy'); }); it('keeps findings that fit beside the short policy instead of dropping them for the full one', () => { const findings = [1, 2, 3].map((line) => finding('side-tab', line, { name: 'X', description: 'short issue' })); const text = renderTemplate(findings, '/x/a.tsx', { ...DEFAULT_CONFIG, limits: { maxFindings: 5, maxChars: 500 } }, { cwd: '/x' }); assert.ok(text.length <= 500); assert.match(text, /- L1 /); assert.match(text, /- L2 /); assert.match(text, /- L3 /, 'all findings survive; the clamp must not drop lines chasing the full policy'); assert.match(text, /Triage per the session policy/); }); }); describe('writeAuditLog()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('appends NDJSON when IMPECCABLE_HOOK_LOG is set', () => { const log = path.join(cwd, 'audit.ndjson'); writeAuditLog({ IMPECCABLE_HOOK_LOG: log }, { event: 'PostToolUse', emitted: true }); writeAuditLog({ IMPECCABLE_HOOK_LOG: log }, { event: 'PostToolUse', emitted: false }); const body = fs.readFileSync(log, 'utf-8'); assert.equal(body.trim().split('\n').length, 2); for (const line of body.trim().split('\n')) { const obj = JSON.parse(line); assert.ok(obj.ts && obj.event === 'PostToolUse'); } }); it('is a no-op when IMPECCABLE_HOOK_LOG is unset', () => { assert.equal(writeAuditLog({}, { event: 'x' }, cwd), false); }); it('falls back to the unified config hook.auditLog when the env var is unset', () => { const log = path.join(cwd, 'from-config.ndjson'); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { auditLog: log } })); assert.equal(writeAuditLog({}, { event: 'PostToolUse' }, cwd), true); assert.equal(fs.readFileSync(log, 'utf-8').trim().split('\n').length, 1); }); it('prefers the env var over config hook.auditLog', () => { const envLog = path.join(cwd, 'from-env.ndjson'); const cfgLog = path.join(cwd, 'from-config.ndjson'); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { auditLog: cfgLog } })); writeAuditLog({ IMPECCABLE_HOOK_LOG: envLog }, { event: 'PostToolUse' }, cwd); assert.equal(fs.existsSync(envLog), true); assert.equal(fs.existsSync(cfgLog), false); }); it('resolves config auditLog from entry.cwd (the event project root), not the fallback cwd', () => { const projectDir = path.join(cwd, 'project'); const log = path.join(cwd, 'event-cwd.ndjson'); fs.mkdirSync(path.join(projectDir, '.impeccable'), { recursive: true }); fs.writeFileSync(path.join(projectDir, '.impeccable', 'config.json'), JSON.stringify({ hook: { auditLog: log } })); // The fallback cwd (root) has no config; entry.cwd points at the project. assert.equal(writeAuditLog({}, { event: 'PostToolUse', cwd: projectDir }, cwd), true); assert.equal(fs.existsSync(log), true); }); it('resolves a relative auditLog path against the project root, not the process cwd', () => { const projectDir = path.join(cwd, 'project'); fs.mkdirSync(path.join(projectDir, '.impeccable'), { recursive: true }); fs.writeFileSync(path.join(projectDir, '.impeccable', 'config.json'), JSON.stringify({ hook: { auditLog: 'logs/hook.ndjson' } })); assert.equal(writeAuditLog({}, { event: 'PostToolUse', cwd: projectDir }, cwd), true); // Written under the project root, not the fallback cwd. assert.equal(fs.existsSync(path.join(projectDir, 'logs', 'hook.ndjson')), true); assert.equal(fs.existsSync(path.join(cwd, 'logs', 'hook.ndjson')), false); }); }); describe('payload()', () => { it('produces hookSpecificOutput for Claude', () => { const obj = JSON.parse(payload('hello')); assert.equal(obj.hookSpecificOutput.hookEventName, 'PostToolUse'); assert.equal(obj.hookSpecificOutput.additionalContext, 'hello'); }); it('keeps Codex PostToolUse on the Claude-compatible context channel', () => { const obj = JSON.parse(payload('hello', 'PostToolUse', 'codex')); assert.equal(obj.hookSpecificOutput.hookEventName, 'PostToolUse'); assert.equal(obj.hookSpecificOutput.additionalContext, 'hello'); }); it('produces a blocking decision for Codex Stop', () => { const obj = JSON.parse(payload('hello', 'Stop', 'codex')); assert.deepEqual(obj, { decision: 'block', reason: 'hello' }); }); it('emits nothing for a Codex Stop with no findings text', () => { assert.equal(payload('', 'Stop', 'codex'), ''); }); it('keeps Claude Stop on the additional-context channel', () => { const obj = JSON.parse(payload('hello', 'Stop', 'claude')); assert.equal(obj.hookSpecificOutput.hookEventName, 'Stop'); assert.equal(obj.hookSpecificOutput.additionalContext, 'hello'); }); it('produces additional_context for Cursor', () => { const obj = JSON.parse(payload('hello', 'PostToolUse', 'cursor')); assert.equal(obj.additional_context, 'hello'); assert.equal(obj.hookSpecificOutput, undefined); }); it('produces top-level additionalContext for GitHub Copilot', () => { const obj = JSON.parse(payload('hello', 'PostToolUse', 'github')); assert.equal(obj.additionalContext, 'hello'); assert.equal(obj.hookSpecificOutput, undefined); assert.equal(obj.additional_context, undefined); }); }); describe('runHook()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function eventFor(file, sessionId = 'sid-1') { return { session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; } function writeFixture(rel, body) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } function writeDesignMd() { fs.writeFileSync(path.join(cwd, 'DESIGN.md'), `--- typography: body: fontFamily: "IBM Plex Sans, Arial, sans-serif" colors: ink: "#241f1a" rounded: md: "8px" --- # Design System `); } function designFinding(value = 'Poppins') { return { ...finding('design-system-font', 1, { name: 'Font outside DESIGN.md', description: 'A font is used that is not declared in DESIGN.md typography.', snippet: `font-family: "${value}", sans-serif;`, }), ignoreValue: value, }; } function designAwareDetector({ stale = false } = {}) { return { loadDesignSystemForCwd: (projectCwd) => ( fs.existsSync(path.join(projectCwd, 'DESIGN.md')) ? { present: true, hasFonts: true, mdNewerThanJson: stale } : null ), detectText: (_content, _filePath, options = {}) => ( options.designSystem ? [designFinding()] : [] ), detectHtml: (_filePath, options = {}) => ( options.designSystem ? [designFinding()] : [] ), }; } it('emits findings on first fire, then a pending-ack on subsequent dedup hits', async () => { // The "no silent fires" policy turns the previously-silent dedup hit // into a pending re-nudge that keeps the unresolved finding in the // model's context across turns. Findings emission still wins outright // over the nudge (`renderTemplate` text), so r1 is unchanged from // before. r2 is what changed: silent → pending ack. const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('text-overflow', 1, { name: 'Content overflow' })]); 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, /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 finding\(s\) flagged earlier this session/); assert.match(r2.stdout, /text-overflow:1/); assert.equal(r2.audit.emitted, true); assert.equal(r2.audit.kind, 'pending'); }); it('handles a GitHub Copilot edit event end-to-end and emits additionalContext', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('gradient-text', 1, { name: 'Gradient text' })]); const githubEvent = { sessionId: 'gh-1', cwd, toolName: 'edit', toolArgs: JSON.stringify({ path: file, old_str: 'a', new_str: 'b' }), }; const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.equal(r.audit.harness, 'github'); assert.equal(r.audit.emitted, true); const out = JSON.parse(r.stdout); assert.ok(out.additionalContext.includes(ENVELOPE_PREFIX)); assert.match(out.additionalContext, /Design hook findings requiring review/); assert.equal(out.hookSpecificOutput, undefined); }); it('handles a Grok Build search_replace event and does not classify it as github (#646)', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('gradient-text', 1, { name: 'Gradient text' })]); const grokEvent = { hookEventName: 'post_tool_use', sessionId: 'grok-1', cwd, workspaceRoot: `${cwd}/`, toolName: 'search_replace', toolInput: { file_path: file, old_string: 'a', new_string: 'b' }, toolResult: { type: 'SearchReplace' }, }; const r = await runHook({ stdinJson: JSON.stringify(grokEvent), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.equal(r.audit.harness, 'grok'); assert.notEqual(r.audit.harness, 'github'); assert.equal(r.audit.emitted, true); assert.equal(r.audit.skipped, undefined); const out = JSON.parse(r.stdout); assert.match(out.hookSpecificOutput.additionalContext, /gradient-text/); const cache = readCache(cwd); assert.ok(cache.sessions['grok-1'].files[file], 'PostToolUse must mark the file for Stop'); assert.deepEqual(cache.sessions['grok-1'].files[file].findings || [], []); }); it('handles a GitHub Copilot apply_patch event end-to-end (interactive/cloud path)', async () => { // The real bug the live test caught: interactive Copilot edits via // apply_patch (raw patch string in toolArgs), which the matcher and runtime // must both cover — not just the edit/create tools seen in `copilot -p`. const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('gradient-text', 1, { name: 'Gradient text' })]); const patch = [ '*** Begin Patch', `*** Update File: ${file}`, '+noop', '*** End Patch', ].join('\n'); const githubEvent = { sessionId: 'gh-ap', cwd, toolName: 'apply_patch', toolArgs: patch }; const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.equal(r.audit.harness, 'github'); assert.equal(r.audit.tool, 'apply_patch'); assert.equal(r.audit.emitted, true); const out = JSON.parse(r.stdout); assert.ok(out.additionalContext.includes(ENVELOPE_PREFIX)); assert.match(out.additionalContext, /Design hook findings requiring review/); }); it('emits a clean ack when the file has zero findings', async () => { // No-silent-fires policy: a successful scan that finds nothing still // emits a short positive nudge so the hook stays a conversational // presence on every fire. const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([]); // no findings const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.ok(r.stdout.includes(ENVELOPE_PREFIX)); assert.match(r.stdout, /No deterministic design-quality issues found/); assert.match(r.stdout, /keep following the project design system and the impeccable skill guidance/); assert.equal(r.audit.emitted, true); assert.equal(r.audit.kind, 'clean'); }); it('does not emit clean acks for plain .ts files', async () => { const file = writeFixture('src/server.ts', 'export const value = 1;'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([]), }); assert.equal(r.exitCode, 0); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'non-ui-ack'); }); it('still emits findings for plain .ts files', async () => { const file = writeFixture('src/styles.ts', 'export const css = "box-shadow: 0 0 24px #7c3aed";'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([finding('dark-glow', 1)]), }); assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /dark-glow/); }); it('does not emit pending acks for plain .js files', async () => { const file = writeFixture('src/build.js', 'export const value = 1;'); const det = fakeDetector([finding('text-overflow', 1)]); const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); 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, ''); assert.equal(second.audit.skipped, 'non-ui-ack'); }); it('IMPECCABLE_HOOK_QUIET=1 suppresses clean and pending acks, keeps findings emission', async () => { // The opt-out kill switch for users who want the old silent-on-clean // behavior. Findings still emit because those are real signals; the // QUIET switch only quiets the conversational acks. const fileA = writeFixture('src/A.tsx', 'noop'); const fileB = writeFixture('src/B.tsx', 'noop'); // Clean file: silent under QUIET. const detClean = fakeDetector([]); const rClean = await runHook({ stdinJson: JSON.stringify(eventFor(fileA)), env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: detClean, }); assert.equal(rClean.stdout, ''); assert.equal(rClean.audit.emitted, false); assert.equal(rClean.audit.quiet, true); // Findings file: still emits. const detFindings = fakeDetector([finding('text-overflow', 1)]); const rFindings = await runHook({ stdinJson: JSON.stringify(eventFor(fileB)), env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: detFindings, }); assert.ok(rFindings.stdout.includes(ENVELOPE_PREFIX)); assert.match(rFindings.stdout, /Design hook findings requiring review/); assert.equal(rFindings.audit.emitted, true); }); it('config quiet:true suppresses the clean ack like the env switch', async () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true } })); const file = writeFixture('src/Quiet.tsx', 'noop'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([]), }); assert.equal(r.stdout, ''); assert.equal(r.audit.quiet, true); }); it('re-entrancy guard short-circuits when IMPECCABLE_HOOK_DEPTH is set', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: { IMPECCABLE_HOOK_DEPTH: '1' }, cwd, detector: det, }); assert.equal(r.stdout, ''); assert.equal(r.audit.reentrant, true); }); it('re-entrancy guard treats numeric CLAUDE_HOOK_DEPTH values as active', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: { CLAUDE_HOOK_DEPTH: '2' }, cwd, detector: det, }); assert.equal(r.stdout, ''); assert.equal(r.audit.reentrant, true); }); it('IMPECCABLE_HOOK_DISABLED kill switch', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1)]); for (const v of ['1', 'true', 'yes', 'on', 'TRUE']) { const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: { IMPECCABLE_HOOK_DISABLED: v }, cwd, detector: det, }); assert.equal(r.stdout, '', `expected silent for value ${v}`); assert.equal(r.audit.skipped, 'env-disabled'); } }); it('config-disabled silences cleanly', async () => { const file = writeFixture('src/Card.tsx', 'noop'); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false } })); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'config-disabled'); }); it('skips the scan when PRODUCT.md declares a native platform', async () => { // The web rule engine has no business flagging React Native screens; the // hook watches .tsx/.ts/.js, which is exactly what a native project is // made of, so the platform field gates the whole scan. for (const platform of ['ios', 'android', 'adaptive']) { writeFixture('PRODUCT.md', `# App\n\n## Register\n\nproduct\n\n## Platform\n\n${platform}\n`); const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, `native-${platform}`)), env: {}, cwd, detector: det }); assert.equal(r.stdout, '', `expected silence for platform ${platform}`); assert.equal(r.audit.skipped, 'native-platform'); assert.equal(r.audit.platform, platform); } }); it('still scans when PRODUCT.md declares web (or has no platform field)', async () => { writeFixture('PRODUCT.md', '# App\n\n## Register\n\nproduct\n\n## Platform\n\nweb\n'); const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('text-overflow', 1, { name: 'Content overflow' })]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'web-platform')), env: {}, cwd, detector: det }); assert.match(r.stdout, /Content overflow/); }); it('only unlocks design-system detector findings when DESIGN.md exists', async () => { const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }'); const det = designAwareDetector(); const withoutDesign = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'design-system-off')), env: {}, cwd, detector: det, }); assert.match(withoutDesign.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(withoutDesign.stdout, /design-system-font/); writeDesignMd(); const withDesign = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'design-system-on')), env: {}, cwd, detector: det, }); assert.match(withDesign.stdout, /Design hook findings requiring review/); assert.match(withDesign.stdout, /design-system-font/); assert.match(withDesign.stdout, /If intentional: `ignore-value design-system-font Poppins`/); }); it('respects detector.designSystem.enabled=false', async () => { writeDesignMd(); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { designSystem: { enabled: false } }, })); const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'design-system-disabled')), env: {}, cwd, detector: designAwareDetector(), }); assert.match(r.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(r.stdout, /design-system-font/); }); it('suppresses design-system findings through ignore-value', async () => { writeDesignMd(); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { ignoreValues: [ { rule: 'design-system-font', value: 'Poppins' }, ], }, })); const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'design-system-ignore-value')), env: {}, cwd, detector: designAwareDetector(), }); assert.match(r.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(r.stdout, /design-system-font/); }); it('adds a non-blocking note when DESIGN.md is newer than the sidecar', async () => { writeDesignMd(); const file = writeFixture('src/Card.tsx', 'noop'); const det = { loadDesignSystemForCwd: () => ({ present: true, mdNewerThanJson: true }), detectText: () => [], detectHtml: () => [], }; const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'design-system-stale-sidecar')), env: {}, cwd, detector: det, }); assert.match(r.stdout, /No deterministic design-quality issues found/); assert.match(r.stdout, /DESIGN\.md is newer than \.impeccable\/design\.json/); assert.match(r.stdout, /\/impeccable document/); }); it('rejects sensitive paths before reading file content', async () => { const file = path.join(cwd, '.env'); fs.writeFileSync(file, 'SECRET=42'); const det = { detectText: () => { throw new Error('should not run'); } }; const r = await runHook({ stdinJson: JSON.stringify({ ...eventFor(file), tool_input: { file_path: file } }), env: {}, cwd, detector: det, }); assert.equal(r.audit.skipped, 'sensitive'); }); it('rejects generated paths', async () => { const file = writeFixture('dist/Card.tsx', 'noop'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd }); assert.equal(r.audit.skipped, 'generated'); }); it('rejects path traversal in file_path', async () => { const r = await runHook({ stdinJson: JSON.stringify({ ...eventFor('/foo/../etc/passwd') }), env: {}, cwd, }); assert.equal(r.audit.skipped, 'sensitive'); }); it('rejects files outside the project, like harness scratchpads', async () => { // Session cwd is a real project; the touched file is a throwaway HTML in // a temp dir elsewhere. Findings against it would be judged with this // project's config and DESIGN.md, so the scan must skip it entirely. fs.writeFileSync(path.join(cwd, 'package.json'), '{"name":"proj"}'); const scratch = mkTmp(); try { const file = path.join(scratch, 'id-test', 'landing.html'); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, '

throwaway

'); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'outside-project'); assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), 'out-of-project edit must not dirty the cache'); } finally { fs.rmSync(scratch, { recursive: true, force: true }); } }); it('still scans a file whose project root is reached through a symlinked cwd', async () => { // macOS /tmp -> /private/tmp style: the session cwd is a symlink to the // project while the tool reports the canonical file path. Containment // compares canonical paths, so this is inside, not outside. const real = path.join(cwd, 'realproj'); const link = path.join(cwd, 'proj-link'); fs.mkdirSync(path.join(real, 'src'), { recursive: true }); fs.writeFileSync(path.join(real, 'package.json'), '{"name":"proj"}'); fs.symlinkSync(real, link); const file = path.join(real, 'src', 'Card.tsx'); fs.writeFileSync(file, 'noop'); const event = { session_id: 'sym-sid', cwd: link, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd: link, detector: fakeDetector([]) }); assert.notEqual(r.audit.skipped, 'outside-project'); assert.match(r.stdout, /No deterministic design-quality issues found/); }); it('rejects extensions outside the allowlist', async () => { const file = writeFixture('docs/README.md', 'noop'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd }); assert.equal(r.audit.skipped, 'extension'); }); it('config ignoreFiles glob suppresses', async () => { const file = writeFixture('src/legacy/Foo.tsx', 'noop'); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { ignoreFiles: ['src/legacy/**'] }, })); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'config-ignore-file'); }); it('emits one-shot suppression notice on the 7th edit and silences after', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1)]); let last; for (let i = 0; i < 8; i++) { // Use a different line each time so we don't dedup; we want to hit // edit-count, not the dedup cache. const f = [finding('side-tab', i + 1)]; last = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: { detectText: () => f, detectHtml: () => f }, }); } // The 7th call (index 6) crosses the threshold; the 8th (index 7) is silent. assert.equal(last.stdout, '', '8th edit should be silent'); assert.equal(last.audit.suppressed, true); }); it('emits suppressionNotice text on the threshold-crossing edit', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1)]); let r; for (let i = 0; i < 7; i++) { const f = [finding('side-tab', i + 1)]; r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: { detectText: () => f, detectHtml: () => f }, }); } assert.ok(r.stdout.includes('Suppressing further design hints')); assert.match(r.stdout, /More than 6 edits in this session reached/); assert.match(r.stdout, /Run \/impeccable audit to revisit/); }); it('handles MultiEdit and apply_patch payload shapes (file_path field)', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1)]); for (const event of [ { ...eventFor(file), tool_name: 'MultiEdit', tool_input: { file_path: file, edits: [] } }, { ...eventFor(file), tool_name: 'apply_patch', tool_input: { file_path: file, command: '...' } }, ]) { const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); // First call emits; second is dedup-silent. Reset by using fresh session. assert.ok(r.stdout.length >= 0); } }); it('parses Codex apply_patch command when file_path is omitted', async () => { writeFixture('src/Card.tsx', '
'); const event = { session_id: 'sid-codex-ap', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: '*** Begin Patch\n*** Update File: src/Card.tsx\n*** End Patch', }, }; const det = fakeDetector([finding('text-overflow', 1)]); const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.match(r.stdout, /Design hook findings requiring review/); }); it('detector throw is swallowed; never breaks turn', async () => { const file = writeFixture('src/Card.tsx', 'noop'); const det = { detectText: () => { throw new Error('boom'); } }; const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.equal(r.stdout, ''); }); it('awaits the real async HTML detector before deciding a page is clean', async () => { // The fixture's finding (side-tab) sits in the deferred tier, so restore // the full per-edit rule set for this test via the config override. fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } })); const file = writeFixture('index.html', [ '', '', '
Feature
', '', ].join('\n')); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: { detectHtml, detectText }, }); assert.match(r.stdout, /Design hook findings requiring review/); assert.doesNotMatch(r.stdout, /No deterministic design-quality issues found/); assert.ok(r.audit.findings > 0); }); it('honors an inline impeccable-disable comment so the hook scans the file clean', async () => { // The hook runs the same engine as `npx impeccable detect`, so an in-file // waiver suppresses hook findings exactly like a config ignore would. // overused-font is deferred-tier; use the perEditRules override so the // per-edit pass surfaces it here. fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } })); const flagged = writeFixture('src/Flagged.tsx', 'const css = "font-family: Inter";'); const flaggedRun = await runHook({ stdinJson: JSON.stringify(eventFor(flagged)), env: {}, cwd, detector: { detectHtml, detectText }, }); assert.match(flaggedRun.stdout, /Design hook findings requiring review/); assert.ok(flaggedRun.audit.findings > 0); const waived = writeFixture('src/Waived.tsx', 'const css = "font-family: Inter"; // impeccable-disable-line overused-font'); const waivedRun = await runHook({ stdinJson: JSON.stringify(eventFor(waived)), env: {}, cwd, detector: { detectHtml, detectText }, }); assert.match(waivedRun.stdout, /No deterministic design-quality issues found/); assert.equal(waivedRun.audit.findings, 0); }); it('malformed stdin → silent skip', async () => { const r = await runHook({ stdinJson: '{not json', env: {}, cwd }); assert.equal(r.audit.skipped, 'stdin-malformed'); }); it('missing file → silent skip (race protection)', async () => { const r = await runHook({ stdinJson: JSON.stringify(eventFor(path.join(cwd, 'src/Vanished.tsx'))), env: {}, cwd, }); assert.equal(r.audit.skipped, 'file-missing'); }); }); describe('runHook() — cache write gating (issues #344, #305)', () => { // The hook must be a no-op on disk in projects that never earned an // `.impeccable/` footprint: skipped files never dirty the cache, and a // dirty cache is only persisted when there are fresh findings or the // project already opted in (an `.impeccable/` dir exists). let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function eventFor(file, sessionId = 'gate-sid') { return { session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; } function write(rel, body, base = cwd) { const abs = path.join(base, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } it('non-UI edit (.md) does not create .impeccable/', async () => { const file = write('notes/todo.md', '# notes'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1)]), }); assert.equal(r.audit.skipped, 'extension'); assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), '.impeccable should not exist'); }); it('clean UI edit in a project with no footprint does not create .impeccable/, still acks', async () => { const file = write('src/Card.tsx', 'noop'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([]), }); assert.match(r.stdout, /No deterministic design-quality issues found/); assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), '.impeccable should not exist'); }); it('detector-missing path does not create .impeccable/', async () => { const file = write('src/Card.tsx', 'noop'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: {}, }); assert.equal(r.audit.skipped, 'detector-missing'); assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), '.impeccable should not exist'); }); it('fresh findings create the cache, and dedup works on the next run', async () => { const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('text-overflow', 1)]); const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.match(first.stdout, /Design hook findings requiring review/); assert.ok(fs.existsSync(path.join(cwd, '.impeccable', 'hook.cache.json')), 'cache should exist'); const second = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.doesNotMatch(second.stdout, /Design hook findings requiring review/); assert.match(second.stdout, /flagged earlier this session/); }); it('clean UI edit in an opted-in project (existing .impeccable/) still persists editCount', async () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); const file = write('src/Card.tsx', 'noop'); await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([]) }); const cache = readCache(cwd); assert.equal(cache.sessions['gate-sid'].files[file].editCount, 1); }); it('umbrella launch keys the cache to the edited file\'s project root', async () => { // cwd is the umbrella: no .git / package.json / .impeccable of its own. write('app/package.json', '{"name":"child"}'); const file = write('app/src/Card.tsx', 'noop'); const child = path.join(cwd, 'app'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([finding('text-overflow', 1)]), }); assert.match(r.stdout, /Design hook findings requiring review/); assert.equal(r.audit.cwd, child); assert.ok(fs.existsSync(path.join(child, '.impeccable', 'hook.cache.json')), 'cache should land in the child project'); assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), 'umbrella root should stay clean'); }); }); describe('runHook() — oversized files', () => { let cwd; beforeEach(() => { cwd = mkTmp(); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); const event = (file) => JSON.stringify({ session_id: 'sid-1', cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }); it('skips a file past the size ceiling, since a huge single file is a bundle', async () => { const file = path.join(cwd, 'bundle.js'); fs.writeFileSync(file, `/* ${'x'.repeat(200 * 1024)} */`); const r = await runHook({ stdinJson: event(file), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]), }); assert.ok(!r.audit.emitted); assert.equal(r.audit.skipped, 'too-large'); }); it('still scans a large but plausibly authored stylesheet', async () => { const file = path.join(cwd, 'main.css'); fs.writeFileSync(file, `/* ${'x'.repeat(90 * 1024)} */`); const r = await runHook({ stdinJson: event(file), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]), }); assert.equal(r.audit.emitted, true); }); // `bytes` describes the file that was skipped. It must never ride along on an // audit entry whose `file` is something else, in either scan order, and must // survive the early-continue paths that sit above the size check. function patchEvent(...files) { return JSON.stringify({ session_id: `sid-${files.length}-${files[0]}`, cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: `*** Begin Patch\n${files.map(f => `*** Update File: ${f}`).join('\n')}\n*** End Patch`, }, }); } it('does not leak a skipped file\'s byte count when the bundle is scanned first', async () => { const big = path.join(cwd, 'bundle.js'); const small = path.join(cwd, 'a.css'); fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`); fs.writeFileSync(small, 'noop'); const r = await runHook({ stdinJson: patchEvent(big, small), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]), }); assert.match(r.stdout, /a\.css/); assert.equal(r.audit.bytes, undefined, 'bytes belongs to the skipped file, not this one'); }); it('does not leak a skipped file\'s byte count when the bundle is scanned last', async () => { const small = path.join(cwd, 'a.css'); const big = path.join(cwd, 'bundle.js'); fs.writeFileSync(small, 'noop'); fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`); const r = await runHook({ stdinJson: patchEvent(small, big), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]), }); assert.match(r.stdout, /a\.css/); assert.equal(r.audit.bytes, undefined, 'the emitted file is not the oversized one'); }); it('does not leak a byte count past an early-continue target', async () => { // `generated` is checked before the size gate, so a later generated target // returns without ever reaching the point where bytes would be cleared. const big = path.join(cwd, 'bundle.js'); const gen = path.join(cwd, 'dist', 'Card.tsx'); fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`); fs.mkdirSync(path.dirname(gen), { recursive: true }); fs.writeFileSync(gen, 'noop'); const r = await runHook({ stdinJson: patchEvent(big, gen), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]), }); assert.ok(!r.audit.emitted); assert.equal(r.audit.bytes, undefined, 'bytes must not describe a different file'); }); it('still records the byte count when the oversized file is the outcome', async () => { const big = path.join(cwd, 'bundle.js'); fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`); const r = await runHook({ stdinJson: patchEvent(big), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]), }); assert.equal(r.audit.skipped, 'too-large'); assert.ok(r.audit.bytes > 200 * 1024, 'the skip reason should still carry its size'); }); it('honors a configured limits.maxFileBytes', async () => { fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({ hook: { limits: { maxFileBytes: 1024 } }, })); const file = path.join(cwd, 'small.css'); fs.writeFileSync(file, `/* ${'x'.repeat(4096)} */`); const r = await runHook({ stdinJson: event(file), env: {}, cwd, detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]), }); assert.equal(r.audit.skipped, 'too-large'); }); }); describe('runHook() — the session cache tracks the current scan', () => { let cwd; beforeEach(() => { cwd = mkTmp(); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function eventFor(file, sessionId = 'sid-1') { return { session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; } // A detector whose findings change between runs, so the cache can be // observed as the file is progressively fixed. function mutableDetector(initial = []) { let current = initial; return { set(next) { current = next; }, detectText: () => current.slice(), detectHtml: () => current.slice(), }; } // An immediate-tier rule, so the per-edit pass reports it rather than // deferring it to the Stop deep pass. These cases are about cache // bookkeeping, not tiering. function fontFinding(line, value) { return { ...finding('design-system-font', line, { name: 'Off-system font' }), ignoreValue: value }; } const run = (file, det) => runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); it('counts the current scan in the pending ack, not the session history', async () => { const file = path.join(cwd, 'a.css'); fs.writeFileSync(file, 'noop'); const det = mutableDetector([ fontFinding(1, 'inter'), fontFinding(2, 'roboto'), fontFinding(3, 'geist'), ]); const r1 = await run(file, det); assert.match(r1.stdout, /\(3 issue\(s\)\)/); // Fix two of the three. The pending ack must not keep naming them. det.set([fontFinding(1, 'inter')]); const r2 = await run(file, det); assert.equal(r2.audit.kind, 'pending'); assert.match(r2.stdout, /Still has 1 finding\(s\)/); assert.match(r2.stdout, /design-system-font:1:inter/); assert.ok(!r2.stdout.includes('roboto'), 'must not name a finding that was fixed'); assert.ok(!r2.stdout.includes('geist'), 'must not name a finding that was fixed'); }); it('reports a reintroduced finding as fresh instead of swallowing it', async () => { const file = path.join(cwd, 'a.css'); fs.writeFileSync(file, 'noop'); const det = mutableDetector([fontFinding(1, 'inter')]); const r1 = await run(file, det); assert.match(r1.stdout, /Design hook findings requiring review/); // Fixed: the hook goes clean and must forget the finding. det.set([]); const r2 = await run(file, det); assert.equal(r2.audit.kind, 'clean'); // Reintroduced: this is a regression and has to surface as fresh, not be // deduped against a stale memory of the same key. det.set([fontFinding(1, 'inter')]); const r3 = await run(file, det); assert.equal(r3.audit.emitted, true); assert.match(r3.stdout, /Design hook findings requiring review/, 'a reintroduced finding must fire again'); }); it('still dedupes an unchanged finding within a session', async () => { // Guard against over-correcting: forgetting fixed findings must not turn // every repeat edit back into a full findings dump. const file = path.join(cwd, 'a.css'); fs.writeFileSync(file, 'noop'); const det = mutableDetector([fontFinding(1, 'inter')]); await run(file, det); const r2 = await run(file, det); assert.equal(r2.audit.kind, 'pending'); assert.match(r2.stdout, /Still has 1 finding\(s\)/); }); }); describe('runHook() — session-scoped notices', () => { let cwd; beforeEach(() => { cwd = mkTmp(); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); const event = (file, sessionId = 'sid-1') => JSON.stringify({ session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }); it('emits the full directive footer once per session, then the short reminder', async () => { const a = path.join(cwd, 'a.css'); const b = path.join(cwd, 'b.css'); fs.writeFileSync(a, 'noop'); fs.writeFileSync(b, 'noop'); const det = fakeDetector([finding('tiny-text', 1, { name: 'Tiny text' })]); const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det }); assert.match(r1.stdout, /Triage each finding/, 'first fresh emission carries the full policy'); const r2 = await runHook({ stdinJson: event(b), env: {}, cwd, detector: det }); assert.match(r2.stdout, /Triage per the session policy/); assert.doesNotMatch(r2.stdout, /Triage each finding/, 'repeat emissions carry the short reminder'); // A new session pays the full policy again. const r3 = await runHook({ stdinJson: event(a, 'sid-2'), env: {}, cwd, detector: det }); assert.match(r3.stdout, /Triage each finding/); }); it('mentions the DESIGN.md staleness note once per session', async () => { const a = path.join(cwd, 'a.css'); const b = path.join(cwd, 'b.css'); fs.writeFileSync(a, 'noop'); fs.writeFileSync(b, 'noop'); const det = { ...fakeDetector([finding('tiny-text', 1, { name: 'Tiny text' })]), loadDesignSystemForCwd: () => ({ present: true, mdNewerThanJson: true }), }; const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det }); assert.match(r1.stdout, /DESIGN\.md is newer than \.impeccable\/design\.json/); const r2 = await runHook({ stdinJson: event(b), env: {}, cwd, detector: det }); assert.ok(r2.audit.emitted, 'second file still emits findings'); assert.doesNotMatch(r2.stdout, /DESIGN\.md is newer/, 'the staleness note does not repeat within a session'); }); it('delivers the staleness note inside the budget on a full first emission', async () => { fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { limits: { maxChars: 500 } }, })); const a = path.join(cwd, 'a.css'); const b = path.join(cwd, 'b.css'); fs.writeFileSync(a, 'noop'); fs.writeFileSync(b, 'noop'); const det = { ...fakeDetector([finding('tiny-text', 1, { name: 'Tiny text', description: 'y'.repeat(600) })]), loadDesignSystemForCwd: () => ({ present: true, mdNewerThanJson: true }), }; // A finding this long would fill the whole budget; the renderer must // reserve room so the note still lands without busting maxChars. const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det }); const ctx1 = JSON.parse(r1.stdout).hookSpecificOutput.additionalContext; assert.ok(ctx1.length <= 500, `final emission honors maxChars (got ${ctx1.length})`); assert.match(ctx1, /DESIGN\.md is newer/, 'the note is delivered on the first emission, not deferred past it'); const r2 = await runHook({ stdinJson: event(b), env: {}, cwd, detector: det }); const ctx2 = JSON.parse(r2.stdout).hookSpecificOutput.additionalContext; assert.doesNotMatch(ctx2, /DESIGN\.md is newer/, 'one mention per session'); }); it('keeps the full-footer flag unspent when the clamp downgrades the footer', async () => { fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { limits: { maxChars: 500 } }, })); const a = path.join(cwd, 'a.css'); fs.writeFileSync(a, 'noop'); const det = fakeDetector([finding('tiny-text', 1, { name: 'Tiny text', description: 'y'.repeat(600) })]); // 500 chars cannot hold the full policy, so the emission carries the // short form. The session must not be marked as having seen the full // footer it never received. const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det }); assert.match(r1.stdout, /Triage per the session policy/); assert.doesNotMatch(r1.stdout, /Triage each finding/); const cache = readCache(cwd); assert.ok(!cache.sessions['sid-1'].footerShown, 'a downgraded footer does not spend the session flag'); }); it('commits the footer flag only for the complete full policy, not its opening words', () => { const cache = { version: 1, sessions: {} }; const full = renderTemplate( [finding('tiny-text', 1, { name: 'Tiny text' })], '/x/a.css', DEFAULT_CONFIG, { cwd: '/x' }, ); // A tail truncation can spare "Triage each finding" while cutting the // policy body. That must not count as delivered. commitFooterShown(cache, 'sid-1', full.slice(0, full.length - 40)); assert.ok(!cache.sessions['sid-1']?.footerShown, 'a truncated policy must not spend the flag'); commitFooterShown(cache, 'sid-1', full); assert.ok(cache.sessions['sid-1'].footerShown, 'the intact policy commits the flag'); }); }); describe('runHook() — clean-ack noise', () => { let cwd; beforeEach(() => { cwd = mkTmp(); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); const event = (file, sessionId = 'sid-1') => JSON.stringify({ session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }); it('emits the clean ack once per file per session, then stays silent', async () => { const a = path.join(cwd, 'a.css'); const b = path.join(cwd, 'b.css'); fs.writeFileSync(a, 'noop'); fs.writeFileSync(b, 'noop'); const det = fakeDetector([]); const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det }); assert.equal(r1.audit.kind, 'clean', 'first clean scan of a file still acks'); const r2 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det }); assert.ok(!r2.audit.emitted, 'repeat clean scans of the same file stay silent'); assert.equal(r2.audit.skipped, 'clean-ack-deduped'); assert.equal(r2.stdout, ''); // A different file gets its own first ack. const r3 = await runHook({ stdinJson: event(b), env: {}, cwd, detector: det }); assert.equal(r3.audit.kind, 'clean'); // A new session starts over, since the steer is per-session context. const r4 = await runHook({ stdinJson: event(a, 'sid-2'), env: {}, cwd, detector: det }); assert.equal(r4.audit.kind, 'clean'); }); it('picks a not-yet-acked file when an earlier target was already acked', async () => { // A multi-file event (apply_patch, MultiEdit) must not lose the ack for a // file the session has never acked just because an earlier target in the // same run was already deduped. const a = path.join(cwd, 'a.css'); const b = path.join(cwd, 'b.css'); fs.writeFileSync(a, 'noop'); fs.writeFileSync(b, 'noop'); const det = fakeDetector([]); // Ack a on its own first. const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det }); assert.equal(r1.audit.kind, 'clean'); // Now touch a and b together. a is spent; b has never been acked. const multi = JSON.stringify({ session_id: 'sid-1', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: `*** Begin Patch\n*** Update File: ${a}\n*** Update File: ${b}\n*** End Patch`, }, }); const r2 = await runHook({ stdinJson: multi, env: {}, cwd, detector: det }); assert.equal(r2.audit.kind, 'clean', 'b has never been acked and should win'); assert.match(r2.stdout, /b\.css/); }); it('reports non-ui-ack when the winner was not ack-eligible, even after a dedupe', async () => { // Mixed multi-target run: one UI file whose ack is already spent, plus a // non-UI file. Nothing is emitted either way, but the audit reason must // describe the winner rather than the earlier dedupe. const css = path.join(cwd, 'a.css'); const ts = path.join(cwd, 'b.ts'); fs.writeFileSync(css, 'noop'); fs.writeFileSync(ts, 'export const a = 1;'); const det = fakeDetector([]); await runHook({ stdinJson: event(css), env: {}, cwd, detector: det }); const multi = JSON.stringify({ session_id: 'sid-1', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: `*** Begin Patch\n*** Update File: ${css}\n*** Update File: ${ts}\n*** End Patch`, }, }); const r = await runHook({ stdinJson: multi, env: {}, cwd, detector: det }); assert.ok(!r.audit.emitted); assert.equal(r.audit.skipped, 'non-ui-ack'); }); it('does not spend the clean ack while quiet mode is suppressing output', async () => { // Quiet emits nothing, so it must not consume the once-per-session ack and // leave a later non-quiet run silent. const file = path.join(cwd, 'a.css'); fs.writeFileSync(file, 'noop'); const det = fakeDetector([]); const quiet = await runHook({ stdinJson: event(file), env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: det }); assert.ok(!quiet.audit.emitted); const loud = await runHook({ stdinJson: event(file), env: {}, cwd, detector: det }); assert.equal(loud.audit.kind, 'clean', 'the ack must survive a quiet run'); }); it('keeps re-nudging with the pending ack, which is the informative one', async () => { const file = path.join(cwd, 'a.css'); fs.writeFileSync(file, 'noop'); const det = fakeDetector([finding('gradient-text', 1, { name: 'Gradient text' })]); await runHook({ stdinJson: event(file), env: {}, cwd, detector: det }); const r2 = await runHook({ stdinJson: event(file), env: {}, cwd, detector: det }); const r3 = await runHook({ stdinJson: event(file), env: {}, cwd, detector: det }); assert.equal(r2.audit.kind, 'pending'); assert.equal(r3.audit.kind, 'pending', 'the unresolved-finding nudge must not be deduped away'); }); }); describe('resolveCacheCwd()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('keeps the session cwd when it already looks like a project root', () => { for (const marker of ['.git', '.impeccable']) { const dir = path.join(cwd, `root-${marker}`); fs.mkdirSync(path.join(dir, marker), { recursive: true }); const file = path.join(dir, 'nested', 'app', 'src', 'Card.tsx'); assert.equal(resolveCacheCwd(file, dir), dir); } const pkgDir = path.join(cwd, 'root-pkg'); fs.mkdirSync(pkgDir, { recursive: true }); fs.writeFileSync(path.join(pkgDir, 'package.json'), '{}'); assert.equal(resolveCacheCwd(path.join(pkgDir, 'src', 'Card.tsx'), pkgDir), pkgDir); }); it('climbs to the nearest marker root when the session cwd is a bare umbrella', () => { const child = path.join(cwd, 'app'); fs.mkdirSync(path.join(child, 'src'), { recursive: true }); fs.writeFileSync(path.join(child, 'package.json'), '{}'); assert.equal(resolveCacheCwd(path.join(child, 'src', 'Card.tsx'), cwd), child); }); it('falls back to the session cwd when no marker is found or the path is unsafe', () => { const file = path.join(cwd, 'app', 'src', 'Card.tsx'); assert.equal(resolveCacheCwd(file, cwd), cwd); assert.equal(resolveCacheCwd('', cwd), cwd); assert.equal(resolveCacheCwd(`${cwd}/../etc/Card.tsx`, cwd), cwd); }); }); describe('suppressionNotice()', () => { it('starts with envelope and mentions /impeccable audit', () => { const text = suppressionNotice('src/Card.tsx'); assert.ok(text.startsWith(ENVELOPE_PREFIX)); assert.match(text, /More than 6 edits in this session reached/); assert.match(text, /\/impeccable audit/); }); }); describe('ALLOWED_EXTS', () => { it('covers the documented design-relevant extensions', () => { for (const ext of ['.tsx', '.jsx', '.html', '.css', '.vue', '.svelte', '.astro', '.ts', '.js', '.scss', '.sass', '.less', '.htm']) { assert.ok(ALLOWED_EXTS.has(ext), `missing: ${ext}`); } for (const ext of ['.md', '.py', '.go', '.json']) { assert.ok(!ALLOWED_EXTS.has(ext), `unexpected allowed: ${ext}`); } }); it('keeps clean/pending acknowledgements to UI-ish files', () => { for (const ext of ['.tsx', '.jsx', '.html', '.css', '.vue', '.svelte', '.astro', '.scss', '.sass', '.less', '.htm']) { assert.ok(ACK_EXTS.has(ext), `missing ack extension: ${ext}`); assert.equal(shouldEmitAckForFile(`/x/src/App${ext}`), true); } for (const ext of ['.ts', '.js']) { assert.ok(!ACK_EXTS.has(ext), `unexpected ack extension: ${ext}`); assert.equal(shouldEmitAckForFile(`/x/src/tool${ext}`), false); } }); it('acks configured html-engine extensions but not text-engine ones', () => { const config = { extensions: [ { ext: '.blade.php', engine: 'html' }, { ext: '.d.ts.hbs', engine: 'text' }, ], }; assert.equal(shouldEmitAckForFile('/x/views/card.blade.php', config), true); assert.equal(shouldEmitAckForFile('/x/templates/types.d.ts.hbs', config), false); assert.equal(shouldEmitAckForFile('/x/views/card.blade.php'), false); }); }); describe('matchConfiguredExtension()', () => { const extensions = [ { ext: '.blade.php', engine: 'html' }, { ext: '.html.erb', engine: 'html' }, { ext: '.twig', engine: 'html' }, ]; it('matches double extensions against the end of the filename', () => { assert.deepEqual( matchConfiguredExtension('/app/resources/views/Card.blade.php', extensions), { ext: '.blade.php', engine: 'html' }, ); assert.deepEqual( matchConfiguredExtension('app/views/users/show.html.erb', extensions), { ext: '.html.erb', engine: 'html' }, ); assert.deepEqual( matchConfiguredExtension('/templates/base.twig', extensions), { ext: '.twig', engine: 'html' }, ); }); it('is case-insensitive on the filename', () => { assert.ok(matchConfiguredExtension('/views/Card.BLADE.PHP', extensions)); }); it('prefers the longest matching suffix regardless of config order', () => { const overlapping = [ { ext: '.php', engine: 'text' }, { ext: '.blade.php', engine: 'html' }, ]; assert.deepEqual( matchConfiguredExtension('/views/card.blade.php', overlapping), { ext: '.blade.php', engine: 'html' }, ); assert.deepEqual( matchConfiguredExtension('/views/card.blade.php', overlapping.slice().reverse()), { ext: '.blade.php', engine: 'html' }, ); assert.deepEqual( matchConfiguredExtension('/app/Controller.php', overlapping), { ext: '.php', engine: 'text' }, ); }); it('does not match unrelated files or bare dotfile-like names', () => { assert.equal(matchConfiguredExtension('/app/Http/Controller.php', extensions), null); assert.equal(matchConfiguredExtension('/views/.blade.php', extensions), null); assert.equal(matchConfiguredExtension('/src/Card.tsx', extensions), null); }); it('returns null for empty or missing config', () => { assert.equal(matchConfiguredExtension('/views/card.blade.php', []), null); assert.equal(matchConfiguredExtension('/views/card.blade.php', undefined), null); }); }); describe('renderCleanAck() / renderPendingAck()', () => { it('renderCleanAck stays short and ends with the steer line', () => { const text = renderCleanAck('/x/src/App.jsx', { cwd: '/x' }); assert.match(text, /^\[impeccable@1\] Design hook scanned src\/App\.jsx\. No deterministic design-quality issues found\./); assert.match(text, /keep following the project design system and the impeccable skill guidance/); // Budget guard: should fit comfortably under a single context-message // injection (~200 chars). Hard upper bound 240 chars. assert.ok(text.length < 240, `clean ack too long: ${text.length} chars`); }); it('renderPendingAck quotes up to 3 known findings and counts the rest', () => { 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 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, /Handle them before finalizing/); }); it('renderPendingAck omits the "+N more" suffix when ≤3 known findings', () => { const text = renderPendingAck('/x/src/A.tsx', ['side-tab:1', 'gradient-text:2'], { cwd: '/x' }); assert.ok(!text.includes('+'), 'no overflow suffix expected'); }); }); describe('parseApplyPatchPaths()', () => { it('extracts absolute and relative paths from patch bodies', () => { const cwd = '/proj'; const rel = parseApplyPatchPaths('*** Update File: src/App.jsx\n', cwd); assert.deepEqual(rel, ['/proj/src/App.jsx']); const abs = parseApplyPatchPaths('*** Add File: /tmp/x.css\n*** Update File: src/y.html\n', cwd); assert.deepEqual(abs, ['/tmp/x.css', '/proj/src/y.html']); }); }); describe('resolveTargetFiles()', () => { it('uses file_path when present and falls back to apply_patch command', () => { assert.deepEqual(resolveTargetFiles({ tool_input: { file_path: '/a/b.tsx' } }, '/proj'), ['/a/b.tsx']); assert.deepEqual( resolveTargetFiles({ tool_name: 'apply_patch', tool_input: { command: '*** Update File: src/x.css\n' } }, '/proj'), ['/proj/src/x.css'], ); assert.deepEqual(resolveTargetFiles({ tool_name: 'Bash', tool_input: { command: 'echo hi' } }, '/proj'), []); }); it('includes every apply_patch file even when file_path is also present', () => { assert.deepEqual( resolveTargetFiles({ tool_name: 'apply_patch', tool_input: { file_path: '/proj/src/App.jsx', command: '*** Update File: src/App.jsx\n*** Update File: src/styles.css\n', }, }, '/proj'), ['/proj/src/App.jsx', '/proj/src/styles.css'], ); }); it('accepts Cursor Write/StrReplace path field and top-level file_path', () => { assert.deepEqual(resolveTargetFiles({ tool_input: { path: '/a/b.tsx' } }, '/proj'), ['/a/b.tsx']); assert.deepEqual(resolveTargetFiles({ file_path: '/a/c.css' }, '/proj'), ['/a/c.css']); }); }); describe('resolveHarness() / normalizeHookEvent()', () => { it('routes explicit env and Cursor conversation_id to cursor harness', () => { assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'cursor' }), 'cursor'); assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'codex' }), 'codex'); assert.equal(resolveHarness({}, { conversation_id: 'c1' }), 'cursor'); assert.equal(resolveHarness({}, { turn_id: 'turn-1' }), 'codex'); assert.equal(resolveHarness({}), 'claude'); }); it('prefers explicit harness and Cursor detection over the Codex turn_id', () => { assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'claude' }, { turn_id: 'turn-1' }), 'claude'); assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'grok' }, { turn_id: 'turn-1' }), 'grok'); assert.equal(resolveHarness({}, { conversation_id: 'c1', turn_id: 'turn-1' }), 'cursor'); assert.equal(resolveHarness({}, { turn_id: '' }), 'claude'); assert.equal(resolveHarness({}, { turn_id: 42 }), 'claude'); }); it('maps Cursor postToolUse Write path into file_path + cwd', () => { const normalized = normalizeHookEvent({ conversation_id: 'c1', workspace_roots: ['/proj'], tool_name: 'Write', tool_input: { path: 'src/App.jsx' }, }, '/fallback', 'cursor'); assert.equal(normalized.session_id, 'c1'); assert.equal(normalized.cwd, '/proj'); assert.equal(normalized.tool_input.file_path, 'src/App.jsx'); }); it('routes a GitHub Copilot postToolUse event (toolName/toolArgs) to the github harness', () => { const event = { sessionId: 's1', cwd: '/proj', toolName: 'edit', toolArgs: '{"path":"src/App.tsx"}' }; assert.equal(resolveHarness({}, event), 'github'); assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'github' }), 'github'); // A Claude/Codex event (tool_name/tool_input) must not be mistaken for github. assert.equal(resolveHarness({}, { tool_name: 'Edit', tool_input: { file_path: 'a.tsx' } }), 'claude'); }); it('routes a Grok Build envelope (toolName/toolInput, no toolArgs) to grok, not github (#646)', () => { const post = { hookEventName: 'post_tool_use', sessionId: 's1', cwd: '/proj', toolName: 'search_replace', toolInput: { file_path: '/proj/src/styles.css' }, }; const stop = { hookEventName: 'stop', sessionId: 's1', cwd: '/proj', reason: 'end_turn', stopHookActive: false, }; assert.equal(resolveHarness({}, post), 'grok'); assert.equal(resolveHarness({}, stop), 'grok'); assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'grok' }), 'grok'); assert.equal(isStopEvent(stop), true); assert.equal(isStopEvent({ hook_event_name: 'Stop' }), true); assert.equal(isStopEvent(post), false); }); it('normalizes a Grok search_replace event onto tool_input.file_path + session_id', () => { const normalized = normalizeHookEvent({ hookEventName: 'post_tool_use', sessionId: 'g1', cwd: '/proj', workspaceRoot: '/proj/', toolName: 'search_replace', toolInput: { file_path: '/proj/src/styles.css', old_string: 'a', new_string: 'b' }, toolResult: { type: 'SearchReplace' }, }, '/fallback', 'grok'); assert.equal(normalized.session_id, 'g1'); assert.equal(normalized.cwd, '/proj'); assert.equal(normalized.tool_name, 'search_replace'); assert.equal(normalized.tool_input.file_path, '/proj/src/styles.css'); assert.deepEqual(resolveTargetFiles(normalized, '/proj'), ['/proj/src/styles.css']); }); it('normalizes a GitHub edit event: JSON-string toolArgs.path -> tool_input.file_path', () => { const normalized = normalizeHookEvent({ sessionId: 's1', cwd: '/proj', toolName: 'edit', toolArgs: '{"path":"/proj/src/App.tsx","old_str":"a","new_str":"b"}', }, '/fallback', 'github'); assert.equal(normalized.session_id, 's1'); assert.equal(normalized.cwd, '/proj'); assert.equal(normalized.tool_name, 'edit'); assert.equal(normalized.tool_input.file_path, '/proj/src/App.tsx'); }); it('normalizes a GitHub apply_patch event: raw patch string -> tool_input.command', () => { // Interactive Copilot and the cloud agent edit via apply_patch, whose // toolArgs is a raw OpenAI-format patch string, not JSON. const patch = [ '*** Begin Patch', '*** Add File: /proj/src/Card.css', "+body { font-family: 'Inter'; }", '*** End Patch', ].join('\n'); const normalized = normalizeHookEvent({ sessionId: 's-ap', cwd: '/proj', toolName: 'apply_patch', toolArgs: patch, }, '/fallback', 'github'); assert.equal(normalized.tool_name, 'apply_patch'); assert.equal(normalized.tool_input.command, patch); // resolveTargetFiles understands apply_patch via tool_input.command. assert.deepEqual(resolveTargetFiles(normalized, '/proj'), ['/proj/src/Card.css']); }); it('does not misroute an edit whose content contains apply_patch markers', () => { // An edit/create payload is JSON; its edited content may legitimately // contain "*** Begin Patch" text (e.g. editing docs about apply_patch). // That must still take the JSON path so `path` is extracted, not be // mistaken for a raw apply_patch payload. const normalized = normalizeHookEvent({ sessionId: 's-edit', cwd: '/proj', toolName: 'edit', toolArgs: JSON.stringify({ path: '/proj/docs/patches.md', old_str: 'old', new_str: '*** Begin Patch\n*** Add File: x\n*** End Patch', }), }, '/fallback', 'github'); assert.equal(normalized.tool_name, 'edit'); assert.equal(normalized.tool_input.file_path, '/proj/docs/patches.md'); assert.equal(normalized.tool_input.command, undefined); assert.deepEqual(resolveTargetFiles(normalized, '/proj'), ['/proj/docs/patches.md']); }); it('normalizes a GitHub create event and tolerates malformed toolArgs', () => { const created = normalizeHookEvent({ sessionId: 's2', cwd: '/proj', toolName: 'create', toolArgs: '{"path":"/proj/styles.css","file_text":"body{}"}', }, '/fallback', 'github'); assert.equal(created.tool_name, 'create'); assert.equal(created.tool_input.file_path, '/proj/styles.css'); const broken = normalizeHookEvent({ sessionId: 's3', cwd: '/proj', toolName: 'edit', toolArgs: 'not json{', }, '/fallback', 'github'); assert.equal(broken.session_id, 's3'); assert.equal(broken.tool_input.file_path, undefined); }); }); describe('expandScanTargets()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function write(rel, body) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } it('includes co-located styles.css when the primary edit is App.jsx', () => { const app = write('src/App.jsx', 'export default function App() { return
; }'); write('src/styles.css', "body { font-family: 'Inter', sans-serif; }"); const expanded = expandScanTargets([app], cwd); assert.deepEqual(expanded, [app, path.join(cwd, 'src/styles.css')]); }); it('includes common co-located Sass, SCSS, and Less stylesheet names', () => { for (const name of ['index.scss', 'index.sass', 'index.less', 'global.scss', 'global.less', 'globals.scss', 'globals.less']) { const dir = `src/${name.replaceAll('.', '-')}`; const app = write(`${dir}/App.jsx`, 'export default function App() { return
; }'); const stylesheet = write(`${dir}/${name}`, ".card\n border-left: 4px solid #3b82f6"); const expanded = expandScanTargets([app], cwd); assert.ok(expanded.includes(stylesheet), `missing ${name}`); } }); it('follows static stylesheet imports from the edited component', () => { const card = write('src/Card.jsx', "import './Card.module.css';\nexport default function Card() { return null; }"); const mod = write('src/Card.module.css', '.card { border-left: 4px solid #3b82f6; }'); const expanded = expandScanTargets([card], cwd); assert.ok(expanded.includes(mod)); }); it('includes co-located module Sass and Less stylesheets', () => { for (const name of ['Card.module.sass', 'Card.module.less']) { const dir = `src/${name.replaceAll('.', '-')}`; const card = write(`${dir}/Card.jsx`, 'export default function Card() { return
; }'); const stylesheet = write(`${dir}/${name}`, '.card { border-left: 4px solid #3b82f6; }'); const expanded = expandScanTargets([card], cwd); assert.ok(expanded.includes(stylesheet), `missing ${name}`); } }); it('resolves relative primary targets against the project cwd', () => { write('src/Card.jsx', "import './Card.module.css';\nexport default function Card() { return null; }"); const mod = write('src/Card.module.css', '.card { border-left: 4px solid #3b82f6; }'); const expanded = expandScanTargets(['src/Card.jsx'], cwd); assert.deepEqual(expanded, [path.join(cwd, 'src/Card.jsx'), mod]); }); it('does not follow imports from traversal-looking primary targets', () => { const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-outside-')); try { fs.writeFileSync(path.join(outside, 'App.jsx'), "import './styles.css';\nexport default function App() { return null; }"); fs.writeFileSync(path.join(outside, 'styles.css'), "body { font-family: 'Inter', sans-serif; }"); const traversalPrimary = `${cwd}/../${path.basename(outside)}/App.jsx`; const expanded = expandScanTargets([traversalPrimary], cwd); assert.deepEqual(expanded, [traversalPrimary]); } finally { fs.rmSync(outside, { recursive: true, force: true }); } }); it('does not expand when the primary target is already a stylesheet', () => { const css = write('src/styles.css', "body { font-family: 'Inter', sans-serif; }"); assert.deepEqual(expandScanTargets([css], cwd), [css]); }); }); describe('runHook() — co-located stylesheet scan', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function write(rel, body) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } it('flags slop in styles.css when only App.jsx was edited', async () => { const app = write('src/App.jsx', 'export default function App() { return
; }'); write('src/styles.css', 'h1 { background-clip: text; color: transparent; }'); const det = { detectText: (content, filePath) => ( filePath.endsWith('.css') ? [finding('gradient-text', 8)] : [] ), detectHtml: () => [], }; const r = await runHook({ stdinJson: JSON.stringify({ session_id: 'co-scan', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: `*** Update File: ${app}\n` }, }), env: {}, cwd, detector: det, }); assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /styles\.css/); }); it('flags slop in co-located .sass when only App.jsx was edited', async () => { const app = write('src/App.jsx', 'export default function App() { return
; }'); write('src/styles.sass', ".card\n box-shadow: 0 0 24px #3b82f6"); const det = { detectText: (content, filePath) => ( filePath.endsWith('.sass') ? [finding('dark-glow', 2)] : [] ), detectHtml: () => [], }; const r = await runHook({ stdinJson: JSON.stringify({ session_id: 'co-scan-sass', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: `*** Update File: ${app}\n` }, }), env: {}, cwd, detector: det, }); assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /styles\.sass/); }); it('emits fresh findings for every file scanned in the same hook run', async () => { const app = write('src/App.jsx', 'export default function App() { return
; }'); const styles = write('src/styles.css', 'h1 { background-clip: text; color: transparent; }'); const seen = []; const det = { detectText: (content, filePath) => { seen.push(filePath); if (filePath.endsWith('App.jsx')) return [finding('text-overflow', 1)]; if (filePath.endsWith('styles.css')) return [finding('gradient-text', 1)]; return []; }, detectHtml: () => [], }; const r = await runHook({ stdinJson: JSON.stringify({ session_id: 'co-scan-fresh-primary', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: `*** Update File: ${app}\n` }, }), env: {}, cwd, detector: det, }); 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, /text-overflow/); assert.match(r.stdout, /gradient-text/); assert.ok(seen.includes(app), 'primary file should be scanned'); assert.ok(seen.includes(styles), 'co-located stylesheet should still be scanned'); assert.equal(r.emission.groups.length, 2); const cache = readCache(cwd); const files = cache.sessions['co-scan-fresh-primary'].files; assert.deepEqual(files[app].findings, ['text-overflow:1']); assert.deepEqual(files[styles].findings, ['gradient-text:1']); }); it('does not bump edit count for passively co-scanned stylesheets', async () => { const app = write('src/App.jsx', 'export default function App() { return
; }'); const styles = write('src/styles.css', 'h1 { background-clip: text; color: transparent; }'); const det = { detectText: (content, filePath) => ( filePath.endsWith('styles.css') ? [finding('gradient-text', 1)] : [] ), detectHtml: () => [], }; const r = await runHook({ stdinJson: JSON.stringify({ session_id: 'co-scan-edit-count', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: `*** Update File: ${app}\n` }, }), env: {}, cwd, detector: det, }); assert.match(r.stdout, /styles\.css/); const cache = readCache(cwd); const files = cache.sessions['co-scan-edit-count'].files; assert.equal(files[app].editCount, 1); assert.equal(files[styles].editCount || 0, 0); }); it('does not scan imported styles from a traversal-looking primary path', async () => { const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-outside-')); try { fs.writeFileSync(path.join(outside, 'App.jsx'), "import './styles.css';\nexport default function App() { return null; }"); fs.writeFileSync(path.join(outside, 'styles.css'), "body { font-family: 'Inter', sans-serif; }"); const traversalPrimary = `${cwd}/../${path.basename(outside)}/App.jsx`; const det = fakeDetector([finding('overused-font', 1, { name: 'Overused font' })]); const r = await runHook({ stdinJson: JSON.stringify({ session_id: 'co-scan-traversal', cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: traversalPrimary }, }), env: {}, cwd, detector: det, }); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'sensitive'); } finally { fs.rmSync(outside, { recursive: true, force: true }); } }); }); describe('runHook() — events without file_path', () => { // The sweep fallback was removed in v5 (single-hook simplification). // Code-execution tools that don't carry a `file_path` now hit a clean // silent skip instead of running a git-status sweep. This keeps the // single PostToolUse matcher (Edit/Write/MultiEdit/apply_patch) honest: // anything else is a no-op. let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('returns silent skip with reason no-file-path', async () => { const event = JSON.stringify({ session_id: 'sid-x', cwd, hook_event_name: 'PostToolUse', tool_name: 'mcp__node_repl__js', tool_input: { title: 'do work', code: 'console.log(1)' }, }); const det = fakeDetector([finding('side-tab', 1)]); const r = await runHook({ stdinJson: event, env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'no-file-path'); }); }); describe('runHook() — configured template extensions (issue #316)', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function eventFor(file) { return { session_id: 'sid-ext', cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; } function writeFixture(rel, body) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } function writeExtensionsConfig(extensions) { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { extensions } })); } function recordingDetector(findings = []) { const calls = { html: [], text: [] }; return { calls, detectHtml: (filePath) => { calls.html.push(filePath); return findings; }, detectText: (_content, filePath) => { calls.text.push(filePath); return findings; }, }; } it('skips .blade.php with no config — the issue #316 repro', async () => { const file = writeFixture('resources/views/card.blade.php', '
Hi
'); const det = recordingDetector([finding('gradient-text', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'extension'); assert.equal(det.calls.html.length + det.calls.text.length, 0); }); it('scans a configured .blade.php through the html engine and emits findings', async () => { writeExtensionsConfig([{ ext: '.blade.php' }]); const file = writeFixture('resources/views/card.blade.php', '
Hi
'); const det = recordingDetector([finding('gradient-text', 1, { name: 'Gradient text' })]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /gradient-text/); assert.equal(r.audit.emitted, true); assert.equal(r.audit.ext, '.blade.php'); assert.deepEqual(det.calls.html, [file]); assert.deepEqual(det.calls.text, []); }); it('routes an engine:text entry through detectText instead', async () => { writeExtensionsConfig([{ ext: '.blade.php', engine: 'text' }]); const file = writeFixture('resources/views/card.blade.php', '
Hi
'); const det = recordingDetector([finding('text-overflow', 1)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.match(r.stdout, /text-overflow/); assert.deepEqual(det.calls.text, [file]); assert.deepEqual(det.calls.html, []); }); it('emits a clean ack for configured html-engine files, stays quiet for text-engine ones', async () => { writeExtensionsConfig([ { ext: '.blade.php' }, { ext: '.d.ts.hbs', engine: 'text' }, ]); const blade = writeFixture('resources/views/clean.blade.php', '
Hi
'); const rBlade = await runHook({ stdinJson: JSON.stringify(eventFor(blade)), env: {}, cwd, detector: recordingDetector([]) }); assert.match(rBlade.stdout, /No deterministic design-quality issues found/); assert.equal(rBlade.audit.kind, 'clean'); const hbs = writeFixture('templates/types.d.ts.hbs', 'export type X = {{name}};'); const rHbs = await runHook({ stdinJson: JSON.stringify(eventFor(hbs)), env: {}, cwd, detector: recordingDetector([]) }); assert.equal(rHbs.stdout, ''); assert.equal(rHbs.audit.skipped, 'non-ui-ack'); }); }); describe('resolveProjectPlatform() / isNativePlatform()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('reads the platform from PRODUCT.md via the same resolution the skill uses', () => { fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\n## Platform\n\nios\n'); assert.equal(resolveProjectPlatform(cwd), 'ios'); }); it('returns null when PRODUCT.md is absent or platform-less', () => { assert.equal(resolveProjectPlatform(cwd), null); fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\nno platform field\n'); assert.equal(resolveProjectPlatform(cwd), null); }); it('isNativePlatform is true only for ios / android / adaptive', () => { assert.equal(isNativePlatform('ios'), true); assert.equal(isNativePlatform('android'), true); assert.equal(isNativePlatform('adaptive'), true); assert.equal(isNativePlatform('web'), false); assert.equal(isNativePlatform(null), false); }); }); describe('Cursor hook scripts', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); it('preToolUse denies proposed writes with detector findings before they land', () => { const logPath = path.join(cwd, 'hook.ndjson'); const filePath = path.join(cwd, 'src/Card.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Write', tool_input: { file_path: filePath, content: `
Hello
`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: logPath }, encoding: 'utf-8', }); const payload = JSON.parse(out); 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, /Triage each finding/); assert.match(payload.agent_message, /Full suppression ladder/, 'the deny message carries the complete policy, not a truncated head'); assert.ok(payload.agent_message.length <= 4000, 'the deny message respects the Cursor cap'); const entries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').map((line) => JSON.parse(line)); assert.equal(entries[0].event, 'preToolUse'); assert.equal(entries[0].blocked, true); assert.equal(entries[0].blockedFindings, 1); }); it('preToolUse delivers the stale-sidecar note within a 500-char budget (PR #508)', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { limits: { maxChars: 500 } }, })); const designMd = path.join(cwd, 'DESIGN.md'); const sidecarPath = path.join(cwd, '.impeccable', 'design.json'); fs.writeFileSync(designMd, `--- typography: body: fontFamily: "IBM Plex Sans, Arial, sans-serif" colors: ink: "#241f1a" rounded: "2xl": "80px" --- # Design System `); fs.writeFileSync(sidecarPath, JSON.stringify({ extensions: { colorMeta: { accent: { canonical: '#b8422e', tonalRamp: ['#d55a42'], }, }, roundedMeta: { lg: { canonical: '24px' }, }, }, })); const past = new Date(Date.now() - 10000); fs.utimesSync(sidecarPath, past, past); const filePath = path.join(cwd, 'src/Card.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', session_id: 'sid-508', cwd, tool_name: 'Write', tool_input: { file_path: filePath, content: `
Hello
`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.agent_message, /DESIGN\.md is newer/); assert.ok(payload.agent_message.length <= 500, `deny message length ${payload.agent_message.length} exceeds 500-char budget`); assert.equal(readCache(cwd).sessions['sid-508'].designNoteShown, true); }); it('preToolUse allows writes with findings when the project platform is native', () => { // Same slop content the deny test blocks, but the project declares a // native platform, so the web rule engine must stand aside. fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\n## Platform\n\nios\n'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Write', tool_input: { file_path: path.join(cwd, 'src/Card.html'), content: `
Hello
`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); assert.deepEqual(JSON.parse(out), { permission: 'allow' }); }); it('preToolUse allows clean proposed writes', () => { const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Write', tool_input: { path: 'src/Card.jsx', streamContent: 'export default function Card() { return
Hello
; }', }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); assert.deepEqual(JSON.parse(out), { permission: 'allow' }); }); it('preToolUse gates configured template extensions (issue #316)', () => { const filePath = path.join(cwd, 'resources/views/card.blade.php'); const content = `
Hello
`; const run = () => execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Write', tool_input: { file_path: filePath, content }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); // Without config the file is invisible to the gate: allowed untouched. assert.equal(JSON.parse(run()).permission, 'allow'); // With a detector.extensions entry the same proposed write is scanned and denied. fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({ detector: { extensions: [{ ext: '.blade.php' }] }, })); const payload = JSON.parse(run()); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /card\.blade\.php/); assert.match(payload.user_message, /side-tab/); }); it('preToolUse routes configured html-engine templates through the HTML engine (issue #316)', () => { // oversized-h1 is only detectable by the static HTML engine (detectText has // no such rule), so a denial here proves the proposed content went through // detectHtml rather than the old always-detectText path. const filePath = path.join(cwd, 'resources/views/hero.blade.php'); fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({ detector: { extensions: [{ ext: '.blade.php' }] }, })); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Write', tool_input: { file_path: filePath, content: '\n

This is a very long headline that keeps going on and on for a while

', }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /oversized-h1/); }); it('preToolUse denies shell heredoc writes that bypass the Write tool', () => { const filePath = path.join(cwd, 'src/ShellCard.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Shell', tool_input: { command: `cat > "${filePath}" <<'EOF'\n\n
Hello
\nEOF\n`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /ShellCard\.html/); assert.match(payload.user_message, /side-tab/); }); it('preToolUse denies Python heredoc file writes that bypass the Write tool', () => { const filePath = path.join(cwd, 'src/PythonCard.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Shell', tool_input: { command: `python3 - <<'PY'\nfrom pathlib import Path\npath = Path('${filePath}')\npath.write_text('''\n
Hello
\n''', encoding='utf-8')\nPY\n`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /PythonCard\.html/); assert.match(payload.user_message, /side-tab/); }); it('preToolUse denies shell append redirects that bypass the Write tool', () => { const filePath = path.join(cwd, 'src/AppendedCard.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Shell', tool_input: { command: `cat >> "${filePath}" <<'EOF'\n\n
Hello
\nEOF\n`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /AppendedCard\.html/); assert.match(payload.user_message, /side-tab/); }); it('preToolUse denies shell tee writes that bypass the Write tool', () => { const filePath = path.join(cwd, 'src/TeeCard.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Shell', tool_input: { command: `cat <<'EOF' | tee -a "${filePath}"\n\n
Hello
\nEOF\n`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /TeeCard\.html/); assert.match(payload.user_message, /side-tab/); }); it('preToolUse denies shell copy writes when copied content has detector findings', () => { const sourcePath = path.join(cwd, 'src/SourceCard.html'); const destPath = path.join(cwd, 'src/CopiedCard.html'); fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); fs.writeFileSync(sourcePath, `
Hello
`); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Shell', tool_input: { command: `cp "${sourcePath}" "${destPath}"`, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /CopiedCard\.html/); assert.match(payload.user_message, /side-tab/); }); it('preToolUse reconstructs Edit old_string/new_string into a full proposed file before scanning', () => { const filePath = path.join(cwd, 'src/EditCard.html'); fs.mkdirSync(path.dirname(filePath), { recursive: true }); const oldString = '
Hello
'; fs.writeFileSync(filePath, oldString); const newString = '\n
Hello
'; const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Edit', tool_input: { file_path: filePath, old_string: oldString, new_string: newString, }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.permission, 'deny'); assert.match(payload.user_message, /EditCard\.html/); assert.match(payload.user_message, /side-tab/); }); it('preToolUse allows fragment-only edits instead of denying on partial context', () => { const filePath = path.join(cwd, 'src/MissingEditCard.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: JSON.stringify({ hook_event_name: 'preToolUse', cwd, tool_name: 'Edit', tool_input: { file_path: filePath, new_string: '
Hello
', }, }), env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); assert.deepEqual(JSON.parse(out), { permission: 'allow' }); }); it('preToolUse downgrades repeated identical denials to allow-with-warning after the edit threshold', () => { const filePath = path.join(cwd, 'src/LoopCard.html'); const input = JSON.stringify({ hook_event_name: 'preToolUse', cwd, session_id: 'cursor-loop', tool_name: 'Write', tool_input: { file_path: filePath, content: '
Hello
', }, }); let payload; for (let i = 0; i < 7; i++) { const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input, env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, encoding: 'utf-8', }); payload = JSON.parse(out); } assert.equal(payload.permission, 'allow'); assert.match(payload.agent_message, /allowing this write to avoid a loop/); const cache = readCache(cwd); const denials = cache.sessions['cursor-loop'].files[filePath].cursorDenials; assert.equal(Object.values(denials)[0], 7); }); it('preToolUse honors truthy IMPECCABLE_HOOK_DISABLED values before stdin parsing', () => { const logPath = path.join(cwd, 'hook.ndjson'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { cwd: path.resolve('.'), input: '{not-json', env: { ...process.env, IMPECCABLE_HOOK_DISABLED: 'true', IMPECCABLE_HOOK_LOG: logPath, }, encoding: 'utf-8', }); assert.deepEqual(JSON.parse(out), { permission: 'allow' }); const entries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').map((line) => JSON.parse(line)); assert.equal(entries[0].event, 'preToolUse'); assert.equal(entries[0].skipped, 'env-disabled'); }); }); describe('runHook() — emission enrichment', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function write(rel, content) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, content); return abs; } it('returns emission.kind fresh with findings on new hits', async () => { write('src/styles.css', 'h1 { background-clip: text; color: transparent; }'); const r = await runHook({ stdinJson: JSON.stringify({ session_id: 'emit-fresh', cwd, hook_event_name: 'PostToolUse', file_path: path.join(cwd, 'src/styles.css'), }), env: { IMPECCABLE_HOOK_HARNESS: 'claude' }, cwd, detector: fakeDetector([finding('gradient-text', 8)]), }); assert.equal(r.emission?.kind, 'fresh'); assert.ok(Array.isArray(r.emission?.findings)); assert.equal(r.emission.findings.length, 1); }); }); describe('runHook() — per-edit tiering', () => { // The per-edit pass surfaces only IMMEDIATE_TIER_RULES; everything else is // deferred to the Stop deep pass. See hook-lib.mjs for the tier rationale. let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function eventFor(file, sessionId = 'tier-sid') { return { session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; } function write(rel, body) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } it('splitFindingsByTier partitions on IMMEDIATE_TIER_RULES', () => { const { immediate, deferred } = splitFindingsByTier([ finding('dark-glow', 1), finding('marketing-buzzword', 2), finding('low-contrast', 3), finding('side-tab', 4), ]); assert.deepEqual(immediate.map((f) => f.antipattern), ['dark-glow', 'low-contrast']); assert.deepEqual(deferred.map((f) => f.antipattern), ['marketing-buzzword', 'side-tab']); for (const f of immediate) assert.ok(IMMEDIATE_TIER_RULES.has(f.antipattern)); }); it('perEditTieringActive is on for claude, off for cursor/github and perEditRules:"all"', () => { assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'claude'), true); assert.equal(perEditTieringActive({ perEditRules: 'all' }, 'claude'), false); assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'github'), false); assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'cursor'), false); assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'grok'), true); assert.equal(perEditTieringActive({}, 'claude'), true); }); it('surfaces immediate-tier findings per edit and defers copy-tier ones', async () => { const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([ finding('marketing-buzzword', 3), finding('dark-glow', 5), ]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /dark-glow/); assert.doesNotMatch(r.stdout, /marketing-buzzword/); assert.equal(r.audit.deferred, 1); const cache = readCache(cwd); assert.deepEqual(cache.sessions['tier-sid'].files[file].findings, ['dark-glow:5']); }); it('emits a clean ack when all findings are deferred, and still marks the file touched', async () => { const file = write('src/Copy.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 2)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-deferred-only')), env: {}, cwd, detector: det }); assert.match(r.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(r.stdout, /marketing-buzzword/); assert.equal(r.audit.deferred, 1); // The touched-file entry is what lets the Stop deep pass find this file. const cache = readCache(cwd); assert.ok(cache.sessions['tier-deferred-only'].files[file], 'file should be marked touched'); assert.deepEqual(cache.sessions['tier-deferred-only'].files[file].findings || [], []); }); it('config hook.perEditRules:"all" restores the full per-edit rule set', async () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } })); const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 2)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-all')), env: {}, cwd, detector: det }); assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /marketing-buzzword/); assert.equal(r.audit.deferred, undefined); }); it('github harness keeps the full rule set per edit (no Stop pass wired there)', async () => { const file = write('src/Card.tsx', 'noop'); const githubEvent = { sessionId: 'gh-tier', cwd, toolName: 'edit', toolArgs: JSON.stringify({ path: file }), }; const det = fakeDetector([finding('marketing-buzzword', 2)]); const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det }); assert.equal(r.audit.harness, 'github'); const out = JSON.parse(r.stdout); assert.match(out.additionalContext, /marketing-buzzword/); }); it('skips advisory findings per edit by default and never nags about them', async () => { const file = write('src/Copy.tsx', 'noop'); const det = fakeDetector([finding('em-dash-overuse', 3)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'adv-skip')), env: {}, cwd, detector: det }); // The only finding is advisory, so the file scans clean. assert.match(r.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(r.stdout, /em-dash-overuse/); }); it('includes advisory findings per edit when detector.advisoryRules is "include"', async () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' }, detector: { advisoryRules: 'include' }, })); const file = write('src/Copy.tsx', 'noop'); const det = fakeDetector([finding('em-dash-overuse', 3)]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'adv-include')), env: {}, cwd, detector: det }); assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /em-dash-overuse/); }); }); describe('runStopHook()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); function write(rel, body) { const abs = path.join(cwd, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); return abs; } function editEvent(file, sessionId) { return { session_id: sessionId, cwd, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: file }, }; } function stopEvent(sessionId) { return { session_id: sessionId, cwd, hook_event_name: 'Stop', stop_hook_active: false, }; } it('runs the full rule set over touched files and dedupes per-edit-surfaced findings', async () => { const sid = 'stop-sid'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([ finding('dark-glow', 5), finding('marketing-buzzword', 3), finding('side-tab', 7), ]); // Per-edit pass: surfaces dark-glow, defers the other two. const edit = await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); assert.match(edit.stdout, /dark-glow/); assert.doesNotMatch(edit.stdout, /marketing-buzzword/); // Stop deep pass: surfaces exactly the deferred remainder. const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(stop.exitCode, 0); assert.equal(stop.audit.emitted, true); const out = JSON.parse(stop.stdout); assert.equal(out.hookSpecificOutput.hookEventName, 'Stop'); assert.match(out.hookSpecificOutput.additionalContext, /marketing-buzzword/); assert.match(out.hookSpecificOutput.additionalContext, /side-tab/); assert.doesNotMatch(out.hookSpecificOutput.additionalContext, /dark-glow/); assert.equal(stop.emission.kind, 'stop-deep-pass'); }); it('emits Codex Stop findings as a blocking decision', async () => { const sid = 'stop-codex'; write('package.json', '{}'); const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 3)]); const editEventCodex = { ...editEvent(file, sid), turn_id: 'turn-1' }; const stopEventCodex = { ...stopEvent(sid), turn_id: 'turn-1' }; const edit = await runHook({ stdinJson: JSON.stringify(editEventCodex), env: {}, cwd, detector: det }); assert.equal(edit.audit.harness, 'codex'); assert.equal(edit.audit.deferred, 1); const editOut = JSON.parse(edit.stdout); assert.ok(editOut.hookSpecificOutput, 'Codex per-edit output stays on the PostToolUse context channel'); assert.equal(editOut.decision, undefined); const stop = await runStopHook({ stdinJson: JSON.stringify(stopEventCodex), env: {}, cwd, detector: det }); assert.equal(stop.exitCode, 0); assert.equal(stop.audit.harness, 'codex'); assert.equal(stop.audit.emitted, true, JSON.stringify(stop.audit)); const out = JSON.parse(stop.stdout); assert.equal(out.decision, 'block'); assert.match(out.reason, /marketing-buzzword/); assert.ok(out.reason.trim().length > 0, 'Codex ignores a block whose reason trims empty'); assert.equal(out.hookSpecificOutput, undefined); }); it('skips the Codex Stop re-fire after a block instead of blocking again', async () => { const sid = 'stop-codex-refire'; write('package.json', '{}'); const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 3)]); await runHook({ stdinJson: JSON.stringify({ ...editEvent(file, sid), turn_id: 'turn-1' }), env: {}, cwd, detector: det, }); const refire = { ...stopEvent(sid), turn_id: 'turn-1', stop_hook_active: true }; const stop = await runStopHook({ stdinJson: JSON.stringify(refire), env: {}, cwd, detector: det }); assert.equal(stop.exitCode, 0); assert.equal(stop.stdout, ''); assert.equal(stop.audit.skipped, 'stop-hook-active'); }); it('keeps a policy footer when the grouped Stop render is clamped to the minimum budget', async () => { const sid = 'stop-clamp'; fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { limits: { maxChars: 500 } } })); const a = write('src/A.tsx', 'noop'); const b = write('src/B.tsx', 'noop'); const det = fakeDetector([finding('side-tab', 1, { name: 'X', description: 'y'.repeat(2000) })]); // Two touched files with deferred findings so the Stop pass groups them. await runHook({ stdinJson: JSON.stringify(editEvent(a, sid)), env: {}, cwd, detector: det }); await runHook({ stdinJson: JSON.stringify(editEvent(b, sid)), env: {}, cwd, detector: det }); const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(stop.audit.emitted, true); const ctx = JSON.parse(stop.stdout).hookSpecificOutput.additionalContext; assert.ok(ctx.length <= 500, `grouped emission honors maxChars (got ${ctx.length})`); assert.match(ctx, /Triage per the session policy/, 'a clamped grouped emission still carries the policy'); assert.match(ctx, /\[side-tab\]/, 'a clamped grouped emission keeps finding detail, not just a file header'); }); it('exits silent and fast when the session touched no UI files', async () => { const r = await runStopHook({ stdinJson: JSON.stringify(stopEvent('stop-untouched')), env: {}, cwd }); assert.equal(r.exitCode, 0); assert.equal(r.stdout, ''); assert.equal(r.audit.skipped, 'no-touched-files'); }); it('skips out-of-project files even when an older cache still lists them', async () => { // Caches written before the containment gate can hold scratchpad paths. // The deep pass re-checks containment instead of trusting the per-edit // pass to have filtered them. const sid = 'stop-outside'; const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-scratch-')); try { const outside = path.join(scratch, 'landing.html'); fs.writeFileSync(outside, '

throwaway

'); persistCache(cwd, { version: 1, sessions: { [sid]: { updatedAt: Date.now(), files: { [outside]: { editCount: 1, findings: [] } } } }, }); const det = fakeDetector([finding('side-tab', 7)]); const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(stop.stdout, ''); assert.equal(stop.audit.skipped, 'stop-clean'); assert.equal(stop.audit.scannedFiles, 0); } finally { fs.rmSync(scratch, { recursive: true, force: true }); } }); it('a second Stop fire is silent: deep-pass findings are remembered', async () => { const sid = 'stop-twice'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 3)]); await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); const first = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.match(first.stdout, /marketing-buzzword/); const second = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(second.stdout, ''); assert.equal(second.audit.skipped, 'stop-clean'); }); it('stays silent when detector.ignoreRules filters away every touched finding', async () => { const sid = 'stop-ignored'; fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { ignoreRules: ['marketing-buzzword'] }, })); const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 3)]); await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(stop.stdout, ''); assert.equal(stop.audit.skipped, 'stop-clean'); }); it('skips advisory findings in the deep pass by default', async () => { const sid = 'stop-advisory'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('em-dash-overuse', 3)]); await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); // Silent either way: the advisory finding is dropped at the per-edit pass, so // the file is never recorded as touched, and the deep pass has nothing to say. assert.equal(stop.stdout, ''); assert.ok(['stop-clean', 'no-touched-files'].includes(stop.audit.skipped)); }); it('surfaces advisory findings in the deep pass when advisoryRules is "include"', async () => { const sid = 'stop-advisory-include'; fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { advisoryRules: 'include' }, })); const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('em-dash-overuse', 3)]); await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(stop.audit.emitted, true); const out = JSON.parse(stop.stdout); assert.match(out.hookSpecificOutput.additionalContext, /em-dash-overuse/); }); it('re-invoked with stop_hook_active:true exits 0 and silent even with pending findings (issue #400)', async () => { const sid = 'stop-active'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 3)]); // Prime a real touched-file + finding so a plain Stop pass would fire. await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); // Re-invocation after the previous fire kept the turn alive: the contract // says exit clean, no re-block, before scanning. const active = { ...stopEvent(sid), stop_hook_active: true }; const stop = await runStopHook({ stdinJson: JSON.stringify(active), env: {}, cwd, detector: det }); assert.equal(stop.exitCode, 0); assert.equal(stop.stdout, ''); assert.equal(stop.audit.emitted, undefined); assert.equal(stop.audit.skipped, 'stop-hook-active'); }); it('stop_hook_active:false or absent still runs the deep pass as before', async () => { const sid = 'stop-inactive'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 3)]); await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); // Explicit false (the stopEvent default). const explicitFalse = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(explicitFalse.audit.emitted, true); assert.match(explicitFalse.stdout, /marketing-buzzword/); // Field absent entirely (legacy / non-Claude-Code payloads): same behavior. const sid2 = 'stop-absent'; const file2 = write('src/Card2.tsx', 'noop'); await runHook({ stdinJson: JSON.stringify(editEvent(file2, sid2)), env: {}, cwd, detector: det }); const ev = stopEvent(sid2); delete ev.stop_hook_active; const absent = await runStopHook({ stdinJson: JSON.stringify(ev), env: {}, cwd, detector: det }); assert.equal(absent.audit.emitted, true); assert.match(absent.stdout, /marketing-buzzword/); }); it('honors kill switches and the re-entrancy guard', async () => { const disabled = await runStopHook({ stdinJson: JSON.stringify(stopEvent('stop-killed')), env: { IMPECCABLE_HOOK_DISABLED: '1' }, cwd, }); assert.equal(disabled.audit.skipped, 'env-disabled'); const reentrant = await runStopHook({ stdinJson: JSON.stringify(stopEvent('stop-reentrant')), env: { IMPECCABLE_HOOK_DEPTH: '1' }, cwd, }); assert.equal(reentrant.audit.reentrant, true); assert.equal(reentrant.stdout, ''); }); function grokEditEvent(file, sessionId) { return { hookEventName: 'post_tool_use', sessionId, cwd, workspaceRoot: `${cwd}/`, toolName: 'search_replace', toolInput: { file_path: file, old_string: 'a', new_string: 'b' }, toolResult: { type: 'SearchReplace' }, }; } function grokStopEvent(sessionId, reason = 'end_turn') { return { hookEventName: 'stop', sessionId, cwd, workspaceRoot: `${cwd}/`, reason, stopHookActive: false, }; } it('Grok Stop end_turn runs the deep pass over files warmed by camelCase PostToolUse (#646)', async () => { const sid = 'grok-stop-sid'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([ finding('dark-glow', 5), finding('marketing-buzzword', 3), ]); const edit = await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); assert.equal(edit.audit.harness, 'grok'); assert.match(edit.stdout, /dark-glow/); assert.doesNotMatch(edit.stdout, /marketing-buzzword/); const stop = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(stop.exitCode, 0); assert.equal(stop.audit.harness, 'grok'); assert.equal(stop.audit.session, sid); assert.equal(stop.audit.emitted, true); const out = JSON.parse(stop.stdout); assert.equal(out.hookSpecificOutput.hookEventName, 'Stop'); // Grok discarded the per-edit stdout, so Stop must still carry the // immediate-tier finding as well as the deferred remainder. assert.match(out.hookSpecificOutput.additionalContext, /dark-glow/); assert.match(out.hookSpecificOutput.additionalContext, /marketing-buzzword/); }); it('Grok Stop re-emits a finding that was fixed then reintroduced', async () => { // Grok PostToolUse only touches the file. Stop is the cache writer. // A clean Stop must replace the remembered set with the empty scan so // the same finding is not deduped away when it comes back. const sid = 'grok-stop-reintro'; const file = write('src/Card.tsx', 'noop'); let current = [finding('dark-glow', 5)]; const det = { set(next) { current = next; }, detectText: () => current.slice(), detectHtml: () => current.slice(), }; await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); const first = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); assert.match(first.stdout, /dark-glow/); det.set([]); const clean = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(clean.stdout, ''); assert.equal(clean.audit.skipped, 'stop-clean'); assert.deepEqual(readCache(cwd).sessions[sid].files[file].findings, []); det.set([finding('dark-glow', 5)]); const again = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(again.audit.emitted, true); assert.match(again.stdout, /dark-glow/, 'a finding fixed then reintroduced must fire at Stop again'); }); it('a Stop detector failure leaves the remembered set alone', async () => { // A throw yields an empty scan; recording that as truth would wipe the // remembered keys and make the next successful Stop re-emit everything. const sid = 'grok-stop-throw'; const file = write('src/Card.tsx', 'noop'); let fail = false; const scan = () => { if (fail) throw new Error('detector crashed'); return [finding('dark-glow', 5)]; }; const det = { detectText: scan, detectHtml: scan }; await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); const first = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); assert.match(first.stdout, /dark-glow/); const remembered = readCache(cwd).sessions[sid].files[file].findings; assert.equal(remembered.length, 1); fail = true; const broken = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(broken.stdout, ''); assert.equal(broken.audit.skipped, 'stop-clean'); assert.deepEqual(readCache(cwd).sessions[sid].files[file].findings, remembered); fail = false; const recovered = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(recovered.stdout, '', 'an unchanged finding must stay deduped after a detector failure'); assert.equal(recovered.audit.skipped, 'stop-clean'); }); it('Stop remembers the live scan, not only newly emitted findings', async () => { // Per-edit already remembered dark-glow. Stop then emits the deferred // remainder. The cache must keep both keys so a second Stop stays silent // instead of re-firing the immediate-tier finding. const sid = 'stop-sync-full-set'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([ finding('dark-glow', 5), finding('marketing-buzzword', 3), ]); await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); const first = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.match(first.stdout, /marketing-buzzword/); assert.doesNotMatch(first.stdout, /dark-glow/); const second = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); assert.equal(second.stdout, ''); assert.equal(second.audit.skipped, 'stop-clean'); }); it('Grok Stop shutdown is observe-only and does not emit a second deep pass (#646)', async () => { const sid = 'grok-shutdown'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('dark-glow', 5)]); await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); const stop = await runStopHook({ stdinJson: JSON.stringify(grokStopEvent(sid, 'shutdown')), env: {}, cwd, detector: det, }); assert.equal(stop.exitCode, 0); assert.equal(stop.stdout, ''); assert.equal(stop.audit.skipped, 'stop-reason'); assert.equal(stop.audit.reason, 'shutdown'); }); it('Grok stopHookActive:true exits silent after camelCase normalize (#646)', async () => { const sid = 'grok-active'; const file = write('src/Card.tsx', 'noop'); const det = fakeDetector([finding('marketing-buzzword', 3)]); await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd, detector: det }); const active = { ...grokStopEvent(sid), stopHookActive: true }; const stop = await runStopHook({ stdinJson: JSON.stringify(active), env: {}, cwd, detector: det }); assert.equal(stop.exitCode, 0); assert.equal(stop.stdout, ''); assert.equal(stop.audit.skipped, 'stop-hook-active'); }); it('hook.mjs routes Grok camelCase stop stdin into runStopHook (#646)', async () => { const sid = 'grok-script-stop'; const file = write('src/hero.css', [ '.hero {', ' background: linear-gradient(#f00, #00f);', ' -webkit-background-clip: text;', ' color: transparent;', '}', '', ].join('\n')); const edit = await runHook({ stdinJson: JSON.stringify(grokEditEvent(file, sid)), env: {}, cwd }); assert.equal(edit.audit.harness, 'grok'); assert.equal(edit.audit.emitted, true); const env = { ...process.env }; delete env.IMPECCABLE_HOOK_DEPTH; delete env.CLAUDE_HOOK_DEPTH; const out = execFileSync(process.execPath, [path.resolve('skill/scripts/hook.mjs')], { cwd, input: JSON.stringify(grokStopEvent(sid)), env, encoding: 'utf-8', }); const payload = JSON.parse(out); assert.equal(payload.hookSpecificOutput.hookEventName, 'Stop'); assert.match(payload.hookSpecificOutput.additionalContext, /gradient-text/); }); });