mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* Fix detector URL and advisory handling Recover joined URL arguments without splitting local paths, derive advisory behavior from registry severity across consumers, inspect readable linked CSS in URL scans, and report only the dominant primary font. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Filter linked CSS to rendered selectors Flatten linked stylesheet grouping rules and collect only selector rules that target the live DOM, preventing unused grouped and selector-less patterns from leaking into URL findings. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Fix detector review edge cases AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction. * Preserve unresolved linked CSS selectors AI assistance disclosure: Codex implemented and verified this fix under maintainer direction. * Fix linked CSS selector filtering Resolve pseudo-element selectors to live hosts, reject unresolvable linked CSS findings, and make the regression assertions independent. Also ignore comment delimiters when recovering CSS rule selectors. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Skip unresolved container query CSS Exclude linked container-query groups when their current applicability cannot be resolved, with a browser regression proving inactive styles do not leak. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Detect active container query CSS Use a temporary custom-property probe so the browser decides whether a nested style rule actually applies in the current container layout. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Filter inactive linked CSS states Keep valid empty pseudo-class matches authoritative and omit selector-less linked at-rules that cannot be tied to rendered nodes. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Parse pseudo-elements without rewriting literals Preserve quoted attribute values and escaped identifiers while resolving real pseudo-elements to live hosts. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Restore live linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Handle grouped linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Respect keyframe definition order AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Resolve effective linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Fix keyframe easing detection Serialize effective per-keyframe easing back into the linked stylesheet corpus so overshoot motion is detected. Add a browser regression with a neutral animation name.\n\nAI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
4641 lines
197 KiB
JavaScript
4641 lines
197 KiB
JavaScript
/**
|
|
* 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 || '<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, '<h1>x</h1>');
|
|
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 `<cwd>/.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; }',
|
|
'<!-- impeccable: ignore side-tab -->',
|
|
'<div style="border-left: 4px solid #7c3aed; border-radius: 16px;">Card</div>',
|
|
].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 <glob>/,
|
|
);
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'side-tab', 'Inter', '--file', 'a.css']),
|
|
/side-tab has no extractable ignore value.*ignore-value side-tab "\*" --file <glob>/,
|
|
);
|
|
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 <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, '<div style="border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px;">Card</div>');
|
|
|
|
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 <rule> "<value>" --reason "<who decided: evidence>"/);
|
|
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 <rule> '<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 <path>`
|
|
// suggestion; directiveFooter() now carries only literal placeholders
|
|
// (`--file <path>`), 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<line> 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, '<h1>throwaway</h1>');
|
|
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', '<div className="border-l-4" />');
|
|
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', [
|
|
'<!doctype html>',
|
|
'<html><body>',
|
|
'<div style="border-left: 4px solid #6366f1; border-radius: 8px; padding: 16px;">Feature</div>',
|
|
'</body></html>',
|
|
].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 <main className="x" />; }');
|
|
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 <main className="x" />; }');
|
|
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 <main className="x" />; }');
|
|
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 <main className="x" />; }');
|
|
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 <main className="x" />; }');
|
|
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 <main className="overflow-hidden" />; }');
|
|
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 <main className="x" />; }');
|
|
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', '<div class="bg-gradient-to-r">Hi</div>');
|
|
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', '<div class="bg-gradient-to-r">Hi</div>');
|
|
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', '<div>Hi</div>');
|
|
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', '<div>Hi</div>');
|
|
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: `
|
|
<style>
|
|
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
|
</style>
|
|
<div class="card">Hello</div>
|
|
`,
|
|
},
|
|
}),
|
|
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: `
|
|
<style>
|
|
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
|
</style>
|
|
<div class="card">Hello</div>
|
|
`,
|
|
},
|
|
}),
|
|
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: `
|
|
<style>
|
|
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
|
</style>
|
|
<div class="card">Hello</div>
|
|
`,
|
|
},
|
|
}),
|
|
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 <section className="card">Hello</section>; }',
|
|
},
|
|
}),
|
|
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 = `
|
|
<style>
|
|
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
|
</style>
|
|
<div class="card">Hello</div>
|
|
`;
|
|
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: '<style>h1 { font-size: 84px; }</style>\n<h1>This is a very long headline that keeps going on and on for a while</h1>',
|
|
},
|
|
}),
|
|
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<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\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('''<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\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<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\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<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\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, `
|
|
<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>
|
|
<div class="card">Hello</div>
|
|
`);
|
|
|
|
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 = '<div class="card">Hello</div>';
|
|
fs.writeFileSync(filePath, oldString);
|
|
const newString = '<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>';
|
|
|
|
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: '<div style="border-left: 4px solid #7c3aed; border-radius: 16px;">Hello</div>',
|
|
},
|
|
}),
|
|
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: '<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style><div class="card">Hello</div>',
|
|
},
|
|
});
|
|
|
|
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, '<h1>throwaway</h1>');
|
|
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/);
|
|
});
|
|
});
|