Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/hook-lib.mjs
1/**2* Shared library for the Impeccable design hook.3*4* Pure-ish helpers split out from `hook.mjs` so unit tests can exercise5* config parsing, finding filtering, dedup, render, and cache logic without6* spawning a subprocess. `hook.mjs` itself is the thin stdin/stdout shim.7*8* Public surface (everything exported is part of the contract):9* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY10* truthy(value)11* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)12* normalizeIgnoreValue(value)13* readCache(cwd) / persistCache(cwd, cache)14* bumpEditCount(cache, sessionId, filePath) -> number15* suppressionNotice(filePath)16* filterFindings(findings, content, ext, config)17* dedupeAgainstCache(findings, cache, sessionId, filePath)18* renderTemplate(findings, filePath, config, opts)19* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)20* shouldEmitAckForFile(filePath)21* writeAuditLog(env, entry)22* loadDetector() -> Promise<{ detectText, detectHtml }>23* matchesAnyGlob(filePath, globs)24* normalizeScanTargets(primaryTargets, projectCwd)25* runHook(deps) -> { exitCode, stdout, audit, reason? }26*27* Design notes:28* - All errors are swallowed at the runHook seam. The detector throwing must29* never break a turn. See PRD §5 "Failure modes".30* - Cache shape is JSON-friendly; we gc the oldest sessions when there are31* more than 8 to keep file size predictable across long-lived projects.32* - The detector loader looks for `detector/detect-antipatterns.mjs` next to33* this file first (built skill layout) and falls back to the repo root's34* `cli/engine/detect-antipatterns.mjs` (running from source).35*/3637import fs from 'node:fs';38import path from 'node:path';39import { pathToFileURL, fileURLToPath } from 'node:url';4041const __filename = fileURLToPath(import.meta.url);42const __dirname = path.dirname(__filename);4344export const ENVELOPE_PREFIX = '[impeccable@1]';4546export const ALLOWED_EXTS = new Set([47'.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',48'.css', '.scss', '.sass', '.less', '.ts', '.js',49]);5051export const ACK_EXTS = new Set([52'.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',53'.css', '.scss', '.sass', '.less',54]);5556// Hard-skip regex for sensitive files. Cannot be turned off via config.57// Match tokenized secret/credential filenames, not UI names such as58// CredentialForm.tsx, SecretPage.jsx, or secretary-dashboard.vue.59export const SENSITIVE_PATH = new RegExp([60String.raw`(?:^|[/\\])\.env(?:\.|$)`,61String.raw`(?:^|[/\\])\.git(?:[/\\]|$)`,62String.raw`(?:^|[/\\])id_rsa(?:$|[._-])[^/\\]*$`,63String.raw`(?:^|[/\\])[^/\\]*\.pem$`,64String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?(?:secret|secrets|credential|credentials)(?=[._-])[^/\\]*\.(?:json|ya?ml|toml|ini|conf|config|env|txt|key|cert|crt|pem|js|ts)$`,65].join('|'), 'i');6667// Hard-skip regex for generated, lock, minified, and build-output paths.68export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i;6970export const TRUTHY = /^(1|true|yes|on)$/i;7172export const DEFAULT_CONFIG = Object.freeze({73enabled: true,74quiet: false,75auditLog: null,76designSystem: { enabled: true },77ignoreRules: [],78ignoreFiles: [],79ignoreValues: [],80limits: { maxFindings: 5, maxChars: 8000 },81});8283export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([84'.impeccable/hook.cache.json',85'.impeccable/hook.pending.json',86'.impeccable/config.local.json',87]);8889const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';90const HOOK_IGNORE_MARKER_CLOSE = '# impeccable-hook-ignore-end';91const CACHE_MAX_SESSIONS = 8;92export const EDIT_COUNT_THRESHOLD = 6;9394export function truthy(value) {95return typeof value === 'string' && TRUTHY.test(value);96}9798function depthIsSet(value) {99if (value === undefined || value === null) return false;100const text = String(value).trim();101if (!text) return false;102if (TRUTHY.test(text)) return true;103return /^\d+$/.test(text) && Number(text) > 0;104}105106function safeReadJson(filePath) {107try {108return JSON.parse(fs.readFileSync(filePath, 'utf-8'));109} catch {110return null;111}112}113114export function getConfigPath(cwd) {115return path.join(cwd, '.impeccable', 'config.json');116}117118export function getLocalConfigPath(cwd) {119return path.join(cwd, '.impeccable', 'config.local.json');120}121122export function getCachePath(cwd) {123return path.join(cwd, '.impeccable', 'hook.cache.json');124}125126export function getPendingPath(cwd) {127return path.join(cwd, '.impeccable', 'hook.pending.json');128}129130export function resolveProjectCwd(event, fallback = process.cwd()) {131return event?.cwd132|| (Array.isArray(event?.workspace_roots) && event.workspace_roots[0])133|| envProjectDir(fallback)134|| fallback;135}136137export function readConfig(cwd) {138const config = cloneDefaultConfig();139// Hook runtime settings live under `hook`; detector filters live under140// `detector`. Back-compat: older configs stored detector filters in `hook`,141// so read those first and let canonical `detector` settings win.142for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {143const raw = safeReadJson(filePath);144applyConfigSource(config, hookSection(raw));145applyDetectorConfigSource(config, detectorSection(raw));146}147return config;148}149150// The hook settings subtree of a unified config.json / config.local.json.151function hookSection(raw) {152if (!raw || typeof raw !== 'object') return null;153return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;154}155156function detectorSection(raw) {157if (!raw || typeof raw !== 'object') return null;158return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;159}160161function numberOr(value, fallback) {162return Number.isFinite(value) && value > 0 ? value : fallback;163}164165function cloneDefaultConfig() {166return {167...DEFAULT_CONFIG,168ignoreRules: [],169ignoreFiles: [],170ignoreValues: [],171designSystem: { ...DEFAULT_CONFIG.designSystem },172limits: { ...DEFAULT_CONFIG.limits },173};174}175176function applyDetectorConfigSource(config, raw) {177if (!raw || typeof raw !== 'object') return config;178if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {179config.designSystem = {180...config.designSystem,181enabled: raw.designSystem.enabled === false ? false : true,182};183}184if (Array.isArray(raw.ignoreRules)) {185config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);186}187if (Array.isArray(raw.ignoreFiles)) {188config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);189}190if (Array.isArray(raw.ignoreValues)) {191config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);192}193return config;194}195196function applyConfigSource(config, raw) {197if (!raw || typeof raw !== 'object') return config;198if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {199config.enabled = raw.enabled === false ? false : true;200}201if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {202config.quiet = raw.quiet === true;203}204if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {205config.auditLog = raw.auditLog.trim();206}207applyDetectorConfigSource(config, raw);208if (raw.limits && typeof raw.limits === 'object') {209config.limits = {210maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),211maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),212};213}214return config;215}216217function uniqueStrings(values) {218return Array.from(new Set(values.map(String)));219}220221export function normalizeIgnoreValue(value) {222return String(value || '')223.trim()224.replace(/^["']|["']$/g, '')225.replace(/\+/g, ' ')226.replace(/\s+/g, ' ')227.toLowerCase();228}229230function normalizeIgnoreRule(rule) {231return String(rule || '').trim().toLowerCase();232}233234function colorIgnoreKey(value) {235const color = parseIgnoreColor(value);236if (!color) return '';237return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;238}239240function parseIgnoreColor(value) {241const text = String(value || '').trim().toLowerCase();242if (!text) return null;243244const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);245if (hex) return parseHexIgnoreColor(hex[1]);246247const rgb = text.match(/^rgba?\((.*)\)$/i);248if (rgb) {249const parts = splitColorArgs(rgb[1]);250if (parts.length < 3 || parts.length > 4) return null;251const r = parseRgbChannel(parts[0]);252const g = parseRgbChannel(parts[1]);253const b = parseRgbChannel(parts[2]);254const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);255if ([r, g, b, a].some((v) => v === null)) return null;256return { r, g, b, a };257}258259const hsl = text.match(/^hsla?\((.*)\)$/i);260if (hsl) {261const parts = splitColorArgs(hsl[1]);262if (parts.length < 3 || parts.length > 4) return null;263const h = parseHueChannel(parts[0]);264const s = parsePercentChannel(parts[1]);265const l = parsePercentChannel(parts[2]);266const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);267if ([h, s, l, a].some((v) => v === null)) return null;268return hslToRgb(h, s, l, a);269}270271return null;272}273274function parseHexIgnoreColor(hex) {275if (hex.length === 3 || hex.length === 4) {276const r = parseInt(hex[0] + hex[0], 16);277const g = parseInt(hex[1] + hex[1], 16);278const b = parseInt(hex[2] + hex[2], 16);279const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;280return { r, g, b, a };281}282const r = parseInt(hex.slice(0, 2), 16);283const g = parseInt(hex.slice(2, 4), 16);284const b = parseInt(hex.slice(4, 6), 16);285const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;286return { r, g, b, a };287}288289function splitColorArgs(body) {290const text = String(body || '').trim();291if (!text) return [];292if (text.includes(',')) {293const parts = text.split(',').map((part) => part.trim()).filter(Boolean);294const last = parts[parts.length - 1];295if (last && last.includes('/')) {296const split = last.split('/').map((part) => part.trim()).filter(Boolean);297return [...parts.slice(0, -1), ...split];298}299return parts;300}301return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');302}303304function parseRgbChannel(raw) {305const text = String(raw || '').trim();306const match = text.match(/^(-?\d*\.?\d+)(%)?$/);307if (!match) return null;308const value = Number.parseFloat(match[1]);309if (!Number.isFinite(value)) return null;310const scaled = match[2] ? value * 2.55 : value;311if (scaled < 0 || scaled > 255) return null;312return Math.round(scaled);313}314315function parseAlphaChannel(raw) {316const text = String(raw || '').trim();317const match = text.match(/^(-?\d*\.?\d+)(%)?$/);318if (!match) return null;319const value = Number.parseFloat(match[1]);320if (!Number.isFinite(value)) return null;321const alpha = match[2] ? value / 100 : value;322return alpha >= 0 && alpha <= 1 ? alpha : null;323}324325function parseHueChannel(raw) {326const text = String(raw || '').trim();327const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);328if (!match) return null;329const value = Number.parseFloat(match[1]);330if (!Number.isFinite(value)) return null;331const unit = match[2] || 'deg';332if (unit === 'turn') return value * 360;333if (unit === 'rad') return value * (180 / Math.PI);334if (unit === 'grad') return value * 0.9;335return value;336}337338function parsePercentChannel(raw) {339const text = String(raw || '').trim();340const match = text.match(/^(-?\d*\.?\d+)%$/);341if (!match) return null;342const value = Number.parseFloat(match[1]);343if (!Number.isFinite(value)) return null;344return value >= 0 && value <= 100 ? value / 100 : null;345}346347function hslToRgb(hue, saturation, lightness, alpha) {348const h = (((hue % 360) + 360) % 360) / 360;349if (saturation === 0) {350const gray = clampByte(Math.round(lightness * 255));351return { r: gray, g: gray, b: gray, a: alpha };352}353const q = lightness < 0.5354? lightness * (1 + saturation)355: lightness + saturation - lightness * saturation;356const p = 2 * lightness - q;357const toRgb = (t) => {358let channel = t;359if (channel < 0) channel += 1;360if (channel > 1) channel -= 1;361if (channel < 1 / 6) return p + (q - p) * 6 * channel;362if (channel < 1 / 2) return q;363if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;364return p;365};366return {367r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),368g: clampByte(Math.round(toRgb(h) * 255)),369b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),370a: alpha,371};372}373374function clampByte(value) {375return Math.min(255, Math.max(0, value));376}377378function ignoreValueMatches(rule, entryValue, findingValue) {379if (entryValue === findingValue) return true;380if (rule !== 'design-system-color') return false;381const entryColor = colorIgnoreKey(entryValue);382return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));383}384385export function normalizeIgnoreValueEntries(entries) {386if (!Array.isArray(entries)) return [];387const out = [];388for (const entry of entries) {389if (!entry || typeof entry !== 'object') continue;390const rule = normalizeIgnoreRule(entry.rule);391const value = normalizeIgnoreValue(entry.value);392if (!rule || !value) continue;393const normalized = { rule, value };394const files = uniqueStrings([395...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),396...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),397]);398if (files.length > 0) normalized.files = files;399if (typeof entry.reason === 'string' && entry.reason.trim()) {400normalized.reason = entry.reason.trim();401}402if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {403normalized.createdAt = entry.createdAt.trim();404}405out.push(normalized);406}407return out;408}409410function mergeIgnoreValues(existing, incoming) {411const map = new Map();412for (const entry of normalizeIgnoreValueEntries(existing)) {413map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);414}415for (const entry of normalizeIgnoreValueEntries(incoming)) {416map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);417}418return Array.from(map.values());419}420421function ignoreValueFilesKey(files) {422return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';423}424425export function readCache(cwd) {426const raw = safeReadJson(getCachePath(cwd));427if (!raw || typeof raw !== 'object' || raw.version !== 1) {428return { version: 1, sessions: {} };429}430return {431version: 1,432sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},433};434}435436export function persistCache(cwd, cache) {437const sessions = cache.sessions || {};438const ids = Object.keys(sessions);439if (ids.length > CACHE_MAX_SESSIONS) {440// Garbage-collect oldest sessions by updatedAt.441const ordered = ids442.map((id) => [id, sessions[id]?.updatedAt || 0])443.sort((a, b) => b[1] - a[1])444.slice(0, CACHE_MAX_SESSIONS);445const next = {};446for (const [id] of ordered) next[id] = sessions[id];447cache = { ...cache, sessions: next };448}449const target = getCachePath(cwd);450try {451ensureHookGitExcludes(cwd);452fs.mkdirSync(path.dirname(target), { recursive: true });453fs.writeFileSync(target, JSON.stringify(cache));454return true;455} catch {456return false;457}458}459460export function ensureHookGitExcludes(cwd = process.cwd()) {461try {462const target = resolveHookGitExcludeTarget(cwd);463if (!target) {464return { mode: 'none', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };465}466467const patterns = target.patternPrefix468? HOOK_LOCAL_IGNORE_PATTERNS.map((pattern) => `${target.patternPrefix}/${pattern}`)469: [...HOOK_LOCAL_IGNORE_PATTERNS];470const markerSuffix = target.patternPrefix || '.';471const markerOpen = `${HOOK_IGNORE_MARKER_OPEN} ${markerSuffix}`;472const markerClose = `${HOOK_IGNORE_MARKER_CLOSE} ${markerSuffix}`;473const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';474const block = [markerOpen, ...patterns, markerClose].join('\n');475const markerRe = new RegExp(`${escapeRegExp(markerOpen)}[\\s\\S]*?${escapeRegExp(markerClose)}`);476477let updated;478if (markerRe.test(existing)) {479updated = existing.replace(markerRe, block);480} else {481const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;482updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;483}484485if (updated !== existing) {486fs.mkdirSync(path.dirname(target.path), { recursive: true });487fs.writeFileSync(target.path, updated, 'utf-8');488}489490return {491mode: 'git-info-exclude',492file: path.relative(path.resolve(cwd), target.path).split(path.sep).join('/'),493changed: updated !== existing,494patterns,495};496} catch {497return { mode: 'error', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };498}499}500501function resolveHookGitExcludeTarget(cwd) {502const start = path.resolve(cwd);503let dir = start;504while (true) {505const dotGit = path.join(dir, '.git');506if (fs.existsSync(dotGit)) {507const gitDir = resolveGitDir(dotGit, dir);508if (!gitDir) return null;509const relPrefix = path.relative(dir, start).split(path.sep).join('/');510return {511path: path.join(gitDir, 'info', 'exclude'),512patternPrefix: relPrefix && relPrefix !== '.' ? relPrefix : '',513};514}515const parent = path.dirname(dir);516if (parent === dir) return null;517dir = parent;518}519}520521function resolveGitDir(dotGit, worktreeDir) {522const stat = fs.statSync(dotGit);523if (stat.isDirectory()) return dotGit;524if (!stat.isFile()) return null;525526const body = fs.readFileSync(dotGit, 'utf-8').trim();527const match = body.match(/^gitdir:\s*(.+)$/i);528if (!match) return null;529return path.isAbsolute(match[1]) ? match[1] : path.resolve(worktreeDir, match[1]);530}531532function escapeRegExp(value) {533return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');534}535536function ensureSession(cache, sessionId) {537if (!cache.sessions[sessionId]) {538cache.sessions[sessionId] = { updatedAt: Date.now(), files: {} };539}540return cache.sessions[sessionId];541}542543function ensureFile(cache, sessionId, filePath) {544const session = ensureSession(cache, sessionId);545if (!session.files[filePath]) {546session.files[filePath] = { editCount: 0, findings: [] };547}548return session.files[filePath];549}550551export function bumpEditCount(cache, sessionId, filePath) {552const fileEntry = ensureFile(cache, sessionId, filePath);553fileEntry.editCount = (fileEntry.editCount || 0) + 1;554ensureSession(cache, sessionId).updatedAt = Date.now();555return fileEntry.editCount;556}557558export function suppressionNotice(filePath) {559return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;560}561562// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.563function globToRegex(glob) {564let re = '^';565let i = 0;566while (i < glob.length) {567const c = glob[i];568if (c === '*') {569if (glob[i + 1] === '*') {570re += '.*';571i += 2;572if (glob[i] === '/') i += 1;573} else {574re += '[^/]*';575i += 1;576}577} else if (c === '?') {578re += '[^/]';579i += 1;580} else if (c === '{') {581const end = glob.indexOf('}', i);582if (end === -1) { re += '\\{'; i += 1; continue; }583const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));584re += `(?:${parts.join('|')})`;585i = end + 1;586} else if (/[.+^$()|[\]\\]/.test(c)) {587re += `\\${c}`;588i += 1;589} else {590re += c;591i += 1;592}593}594re += '$';595return new RegExp(re);596}597598export function matchesAnyGlob(filePath, globs) {599if (!Array.isArray(globs) || globs.length === 0) return false;600const normalized = filePath.split(path.sep).join('/');601for (const glob of globs) {602try {603const re = globToRegex(String(glob));604if (re.test(normalized)) return true;605// Match against basename too for convenience: `*.generated.tsx` should606// catch `src/foo.generated.tsx` without requiring `**/`.607const base = normalized.split('/').pop();608if (re.test(base)) return true;609} catch {610/* malformed glob, skip */611}612}613return false;614}615616export function filterFindings(findings, _content, _ext, config) {617if (!Array.isArray(findings) || findings.length === 0) return [];618const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));619const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);620return findings.filter((f) => {621if (!f || typeof f !== 'object') return false;622if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;623if (isIgnoredFindingValue(f, ignoreValues)) return false;624return true;625});626}627628function isIgnoredFindingValue(finding, ignoreValues) {629if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;630const rule = normalizeIgnoreRule(finding.antipattern);631const value = extractFindingIgnoreValue(finding);632if (!rule || !value) return false;633return ignoreValues.some((entry) => {634const wildcardValue = entry.value === '*';635if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;636if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;637return findingMatchesScopedIgnoreFile(finding, entry.files);638});639}640641function findingMatchesScopedIgnoreFile(finding, globs) {642const filePath = String(finding?.file || '').trim();643if (!filePath) return false;644if (matchesAnyGlob(filePath, globs)) return true;645646const normalized = filePath.split(path.sep).join('/');647const parts = normalized.split('/').filter(Boolean);648for (let i = 0; i < parts.length; i++) {649const suffix = parts.slice(i).join('/');650if (matchesAnyGlob(suffix, globs)) return true;651}652return false;653}654655export function extractFindingIgnoreValue(finding) {656if (!finding || typeof finding !== 'object') return '';657const rule = normalizeIgnoreRule(finding.antipattern);658const directValueRules = new Set([659'overused-font',660'bounce-easing',661'design-system-font',662'design-system-color',663'design-system-radius',664]);665if (!directValueRules.has(rule)) return '';666return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));667}668669function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {670const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');671if (direct) return direct;672673const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);674for (const text of candidates) {675if (rule === 'bounce-easing') {676const motion = extractMotionIgnoreValue(text);677if (motion) return motion;678continue;679}680681const primary = text.match(/Primary font:\s*([^()\n;]+)/i);682if (primary) return cleanIgnoreValueDisplay(primary[1]);683684const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);685if (family) return cleanIgnoreValueDisplay(family[1]);686687const google = text.match(/[?&]family=([^&:;\n]+)/i);688if (google) {689try {690return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));691} catch {692return cleanIgnoreValueDisplay(google[1]);693}694}695}696697return '';698}699700function extractMotionIgnoreValue(text) {701const tailwind = text.match(/\banimate-bounce\b/i);702if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);703704const bezier = text.match(/cubic-bezier\([^)]+\)/i);705if (bezier) return cleanIgnoreValueDisplay(bezier[0]);706707const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);708if (animation) {709const token = animation[1]710.split(/[,\s]+/)711.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));712if (token) return cleanIgnoreValueDisplay(token);713}714715return '';716}717718function cleanIgnoreValueDisplay(value) {719return String(value || '')720.trim()721.replace(/^["']|["']$/g, '')722.replace(/\+/g, ' ')723.replace(/\s+/g, ' ');724}725726export function dedupeAgainstCache(findings, cache, sessionId, filePath) {727if (!Array.isArray(findings) || findings.length === 0) return [];728const fileEntry = ensureFile(cache, sessionId, filePath);729const known = new Set(fileEntry.findings || []);730const fresh = [];731for (const f of findings) {732const key = findingCacheKey(f);733if (known.has(key)) continue;734known.add(key);735fresh.push(f);736}737return fresh;738}739740export function rememberFindings(cache, sessionId, filePath, findings) {741const fileEntry = ensureFile(cache, sessionId, filePath);742const known = new Set(fileEntry.findings || []);743for (const f of findings) known.add(findingCacheKey(f));744fileEntry.findings = Array.from(known);745ensureSession(cache, sessionId).updatedAt = Date.now();746}747748function findingCacheKey(finding) {749const line = finding?.line || 0;750const value = extractFindingIgnoreValue(finding);751if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`;752if (line > 0) return `${finding.antipattern}:${line}`;753if (value) return `${finding.antipattern}:0:${value}`;754const snippet = String(finding?.snippet || '').trim().slice(0, 80);755return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`;756}757758export function renderTemplate(findings, filePath, config, opts = {}) {759if (!Array.isArray(findings) || findings.length === 0) return '';760const limits = config?.limits || DEFAULT_CONFIG.limits;761const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);762const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);763764const cwd = opts.cwd || process.cwd();765const display = relativize(filePath, cwd);766const total = findings.length;767const shown = findings.slice(0, cap);768const remaining = total - shown.length;769770const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;771const lines = shown.map((f) => formatFindingLine(f));772const more = remaining > 0773? `... and ${remaining} more (see /impeccable audit).`774: null;775const footer = directiveFooter(display);776777const blocks = [header, ...lines];778if (more) blocks.push(more);779blocks.push('');780blocks.push(footer);781let text = blocks.join('\n');782783if (text.length > maxChars) {784text = clampToBudget(header, lines, more, footer, maxChars);785}786return text;787}788789function renderGroupedTemplate(groups, config, opts = {}) {790const realGroups = groups.filter((group) => Array.isArray(group.findings) && group.findings.length > 0);791if (realGroups.length === 0) return '';792if (realGroups.length === 1) {793const [group] = realGroups;794return renderTemplate(group.findings, group.filePath, config, opts);795}796797const limits = config?.limits || DEFAULT_CONFIG.limits;798const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);799const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);800const cwd = opts.cwd || process.cwd();801const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);802const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;803const lines = [];804let shownCount = 0;805806for (const group of realGroups) {807const display = relativize(group.filePath, cwd);808lines.push(`${display} (${group.findings.length} issue(s)):`);809const remainingCap = Math.max(0, cap - shownCount);810const shown = group.findings.slice(0, remainingCap);811for (const finding of shown) {812lines.push(formatFindingLine(finding));813}814shownCount += shown.length;815const hidden = group.findings.length - shown.length;816if (hidden > 0) {817lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);818}819}820821const footer = directiveFooter('the affected files', { grouped: true });822let text = [header, ...lines, '', footer].join('\n');823if (text.length > maxChars) {824text = clampGroupedToBudget(header, lines, footer, maxChars);825}826return text;827}828829function clampGroupedToBudget(header, lines, footer, maxChars) {830const assemble = (linesArr, omitted) => [831header,832...linesArr,833...(omitted ? ['... and more (see /impeccable audit).'] : []),834'',835footer,836].join('\n');837838let working = lines.slice();839let omitted = false;840let assembled = assemble(working, omitted);841while (assembled.length > maxChars && working.length > 1) {842working.pop();843omitted = true;844assembled = assemble(working, omitted);845}846if (assembled.length > maxChars) {847assembled = `${assembled.slice(0, maxChars - 1)}…`;848}849return assembled;850}851852function clampToBudget(header, lines, more, footer, maxChars) {853const assemble = (linesArr, moreText) => {854const blocks = [header, ...linesArr];855if (moreText) blocks.push(moreText);856blocks.push('');857blocks.push(footer);858return blocks.join('\n');859};860861let working = lines.slice();862let moreText = more;863let assembled = assemble(working, moreText);864while (assembled.length > maxChars && working.length > 1) {865working.pop();866moreText = '... and more (see /impeccable audit).';867assembled = assemble(working, moreText);868}869if (assembled.length > maxChars) {870assembled = `${assembled.slice(0, maxChars - 1)}…`;871}872return assembled;873}874875function formatFindingLine(f) {876const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';877const desc = (f.description || '').trim();878const name = (f.name || '').trim();879// Description from the registry already ends in punctuation; join with a880// single space. `name` may have a trailing period already, keep it clean.881const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';882const ignoreCommand = formatFindingIgnoreCommand(f);883const ignoreSegment = ignoreCommand884? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`885: '';886return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();887}888889function formatFindingIgnoreCommand(finding) {890if (!finding || typeof finding !== 'object') return '';891const rule = normalizeIgnoreRule(finding.antipattern);892if (!rule) return '';893const normalizedValue = extractFindingIgnoreValue(finding);894if (!normalizedValue) return '';895const value = extractFindingIgnoreValueRaw(finding);896const valueArg = quoteCommandArg(value);897const reason = quoteCommandArg(`User confirmed ${value} is intentional`);898return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;899}900901function quoteCommandArg(value) {902const text = String(value || '').trim();903if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;904return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;905}906907function relativize(filePath, cwd) {908try {909const rel = path.relative(cwd, filePath);910if (!rel || rel.startsWith('..')) return filePath;911return rel.split(path.sep).join('/');912} catch {913return filePath;914}915}916917// Codex `apply_patch` exposes the raw patch in `tool_input.command`, not918// `tool_input.file_path`. Claude Code may send both; parse the patch body919// so we can scan the file(s) the tool actually touched.920// https://developers.openai.com/codex/hooks#posttooluse921const APPLY_PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;922923export function parseApplyPatchPaths(command, projectCwd) {924if (!command || typeof command !== 'string') return [];925const out = [];926for (const m of command.matchAll(APPLY_PATCH_FILE_RE)) {927let p = (m[1] || '').trim();928if (!p) continue;929if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);930out.push(p);931}932return out;933}934935export function resolveTargetFiles(event, projectCwd) {936const ti = event?.tool_input;937const out = [];938const add = (filePath) => {939if (typeof filePath !== 'string' || !filePath) return;940if (!out.includes(filePath)) out.push(filePath);941};942943if (event?.tool_name === 'apply_patch' && ti && typeof ti.command === 'string') {944for (const filePath of parseApplyPatchPaths(ti.command, projectCwd)) add(filePath);945}946if (ti && typeof ti.file_path === 'string' && ti.file_path) {947add(ti.file_path);948}949// Cursor Write / StrReplace use `path`, not `file_path`.950if (ti && typeof ti.path === 'string' && ti.path) {951add(ti.path);952}953if (typeof event?.file_path === 'string' && event.file_path) {954add(event.file_path);955}956return out;957}958959export function resolveHarness(env = {}, event = null) {960const explicit = env?.IMPECCABLE_HOOK_HARNESS;961if (explicit === 'cursor') return 'cursor';962if (explicit === 'github') return 'github';963if (explicit === 'claude' || explicit === 'codex') return 'claude';964// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and965// has no `tool_name`/`tool_input`. That shape is the discriminator.966if (event && typeof event === 'object'967&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)968&& event.tool_name === undefined && event.tool_input === undefined) {969return 'github';970}971if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';972return 'claude';973}974975// GitHub Copilot's postToolUse payload is976// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }977// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.978// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a979// JSON *string* (double-encoded) carrying the file under `path`, e.g.980// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",981// while `apply_patch` sends a raw OpenAI-format patch string (handled below in982// normalizeGitHubEvent). The detector reads the file from disk after the tool983// ran, so only the path (not the proposed content) is needed here.984export function parseGitHubToolArgs(toolArgs) {985if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;986if (typeof toolArgs === 'string' && toolArgs.trim()) {987try {988const parsed = JSON.parse(toolArgs);989return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};990} catch {991return {};992}993}994return {};995}996997// Copilot's `apply_patch` tool (used by interactive sessions and the cloud998// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:999// *** Begin Patch1000// *** Add File: /abs/app.css1001// +body { ... }1002// *** End Patch1003// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a1004// JSON string with the path under `path`. Both must map onto the internal shape.1005const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;10061007function looksLikeApplyPatch(rawArgs) {1008if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;1009// Guard against an edit/create payload whose edited *content* happens to1010// contain patch markers: that payload is a JSON object string, whereas a real1011// apply_patch payload is a raw patch string that does not parse as JSON. Only1012// treat non-JSON-object strings as apply_patch so edit events still get their1013// `path` extracted.1014try {1015const parsed = JSON.parse(rawArgs);1016if (parsed && typeof parsed === 'object') return false;1017} catch { /* not JSON → genuine raw patch */ }1018return true;1019}10201021function applyPatchText(rawArgs) {1022if (typeof rawArgs === 'string') {1023if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;1024// Defensive: a future Copilot build might JSON-wrap the patch.1025const parsed = parseGitHubToolArgs(rawArgs);1026return parsed.patch || parsed.input || parsed.command || '';1027}1028if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {1029return rawArgs.patch || rawArgs.input || rawArgs.command || '';1030}1031return '';1032}10331034function normalizeGitHubEvent(event, projectCwd) {1035const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;1036const sessionId = event.sessionId || event.session_id || 'unknown';1037const toolName = event.toolName || event.tool_name || null;1038const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};1039const rawArgs = event.toolArgs;10401041let normalizedToolName = toolName;1042if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {1043// resolveTargetFiles() reads the touched paths from tool_input.command when1044// tool_name is 'apply_patch', so normalize the name even if a future build1045// sends the patch under a different tool label.1046const patch = applyPatchText(rawArgs);1047if (patch) {1048toolInput.command = patch;1049normalizedToolName = 'apply_patch';1050}1051} else {1052const args = parseGitHubToolArgs(rawArgs);1053const filePath = args.path || args.file_path || args.filePath || args.target_file;1054if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;1055}10561057return {1058...event,1059cwd,1060session_id: sessionId,1061tool_name: normalizedToolName,1062tool_input: toolInput,1063};1064}10651066export function normalizeHookEvent(event, projectCwd, harness = 'claude') {1067if (!event || typeof event !== 'object') return event;1068if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);1069if (harness !== 'cursor') return event;10701071const cwd = event.cwd1072|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])1073|| envProjectDir(projectCwd)1074|| projectCwd;1075const sessionId = event.session_id || event.conversation_id || 'unknown';10761077const ti = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};1078const filePath = ti.file_path || ti.path || event.file_path;1079if (filePath) {1080return {1081...event,1082cwd,1083session_id: sessionId,1084tool_input: { ...ti, file_path: filePath },1085};1086}10871088return { ...event, cwd, session_id: sessionId };1089}10901091function envProjectDir(fallback) {1092if (typeof process.env.CURSOR_PROJECT_DIR === 'string' && process.env.CURSOR_PROJECT_DIR) {1093return process.env.CURSOR_PROJECT_DIR;1094}1095return fallback;1096}10971098// UI components often keep slop in a sibling/co-located stylesheet while the1099// JSX edit is what triggered PostToolUse. Scan those styles too so an App.jsx1100// patch doesn't report "clean" while styles.css still has Inter/bounce/etc.1101const UI_CODE_EXTS = new Set(['.jsx', '.tsx', '.vue', '.svelte', '.astro']);1102const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);1103const CO_SCAN_STYLE_NAMES = [1104'styles.css', 'styles.scss', 'styles.sass', 'styles.less',1105'index.css', 'index.scss', 'index.sass', 'index.less',1106'global.css', 'global.scss', 'global.sass', 'global.less',1107'globals.css', 'globals.scss', 'globals.sass', 'globals.less',1108];1109const MAX_SCAN_TARGETS = 6;11101111const STATIC_STYLE_IMPORT_RE = /import\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]/gi;11121113function hasPathTraversal(filePath) {1114return typeof filePath === 'string' && filePath.includes('..');1115}11161117function isInsideProject(filePath, projectCwd) {1118if (!filePath || !projectCwd || hasPathTraversal(filePath)) return false;1119try {1120const rel = path.relative(projectCwd, filePath);1121return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));1122} catch {1123return false;1124}1125}11261127export function parseStaticStyleImports(content, fromFile, projectCwd) {1128if (!content || typeof content !== 'string') return [];1129const dir = path.dirname(fromFile);1130const out = [];1131for (const m of content.matchAll(STATIC_STYLE_IMPORT_RE)) {1132let p = (m[1] || '').trim();1133if (!p) continue;1134if (p.startsWith('.')) p = path.resolve(dir, p);1135else if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);1136if (!isInsideProject(p, projectCwd)) continue;1137out.push(p);1138}1139return out;1140}11411142export function coLocatedStylesheets(filePath) {1143const dir = path.dirname(filePath);1144const base = path.basename(filePath, path.extname(filePath));1145const candidates = new Set([1146path.join(dir, `${base}.css`),1147path.join(dir, `${base}.module.css`),1148path.join(dir, `${base}.scss`),1149path.join(dir, `${base}.module.scss`),1150path.join(dir, `${base}.sass`),1151path.join(dir, `${base}.module.sass`),1152path.join(dir, `${base}.less`),1153path.join(dir, `${base}.module.less`),1154]);1155for (const name of CO_SCAN_STYLE_NAMES) {1156candidates.add(path.join(dir, name));1157}1158return [...candidates].filter((p) => fs.existsSync(p));1159}11601161export function normalizeScanTargets(primaryTargets, projectCwd) {1162if (!Array.isArray(primaryTargets) || primaryTargets.length === 0) return [];1163const ordered = [];1164const seen = new Set();1165const baseCwd = projectCwd || process.cwd();1166const normalizeTarget = (p) => {1167// Preserve literal `..` segments so downstream sensitive-path checks1168// still fire. path.resolve would collapse `/foo/../etc/passwd`.1169if (hasPathTraversal(p)) return p;1170return path.isAbsolute(p) ? p : path.resolve(baseCwd, p);1171};1172const add = (p) => {1173if (ordered.length >= MAX_SCAN_TARGETS) return;1174const abs = normalizeTarget(p);1175if (seen.has(abs)) return;1176seen.add(abs);1177ordered.push(abs);1178return abs;1179};11801181for (const p of primaryTargets) add(p);1182return ordered;1183}11841185export function expandScanTargets(primaryTargets, projectCwd) {1186const ordered = normalizeScanTargets(primaryTargets, projectCwd);1187if (ordered.length === 0) return [];1188const seen = new Set(ordered);1189const baseCwd = projectCwd || process.cwd();1190const add = (p) => {1191if (ordered.length >= MAX_SCAN_TARGETS) return;1192const abs = hasPathTraversal(p) ? p : (path.isAbsolute(p) ? p : path.resolve(baseCwd, p));1193if (seen.has(abs)) return;1194seen.add(abs);1195ordered.push(abs);1196return abs;1197};11981199const normalizedPrimaries = [];1200for (const p of ordered) normalizedPrimaries.push(p);12011202for (const p of normalizedPrimaries) {1203if (ordered.length >= MAX_SCAN_TARGETS) break;1204if (!isInsideProject(p, baseCwd)) continue;1205const ext = path.extname(p).toLowerCase();1206if (STYLE_EXTS.has(ext) || !UI_CODE_EXTS.has(ext)) continue;12071208let content = '';1209try { content = fs.readFileSync(p, 'utf-8'); } catch { /* unreadable primary */ }12101211for (const imp of parseStaticStyleImports(content, p, projectCwd)) {1212add(imp);1213if (ordered.length >= MAX_SCAN_TARGETS) break;1214}1215for (const col of coLocatedStylesheets(p)) {1216add(col);1217if (ordered.length >= MAX_SCAN_TARGETS) break;1218}1219}12201221return ordered;1222}12231224export function writeAuditLog(env, entry, cwd = process.cwd()) {1225// The event's project root (entry.cwd) when present, else the passed cwd. Both1226// config reads and relative log paths resolve against this, since the hook1227// process cwd can differ from the project being edited.1228const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;1229// Env wins; otherwise fall back to the unified config's hook.auditLog path.1230let target = env?.IMPECCABLE_HOOK_LOG;1231if (!target || typeof target !== 'string') {1232try { target = readConfig(baseCwd).auditLog; } catch { target = null; }1233}1234if (!target || typeof target !== 'string') return false;1235try {1236let expanded;1237if (target.startsWith('~/')) {1238expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));1239} else if (path.isAbsolute(target)) {1240expanded = target;1241} else {1242expanded = path.resolve(baseCwd, target);1243}1244fs.mkdirSync(path.dirname(expanded), { recursive: true });1245const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';1246fs.appendFileSync(expanded, line);1247return true;1248} catch {1249return false;1250}1251}12521253const DETECTOR_CANDIDATES = [1254path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),1255path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),1256path.join(__dirname, '..', '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),1257];12581259let detectorCache = null;1260export async function loadDetector(candidates = DETECTOR_CANDIDATES) {1261if (detectorCache) return detectorCache;1262const found = candidates.find((c) => fs.existsSync(c));1263if (!found) return null;1264const mod = await import(pathToFileURL(found));1265detectorCache = {1266detectText: mod.detectText,1267detectHtml: mod.detectHtml,1268loadDesignSystemForCwd: mod.loadDesignSystemForCwd,1269};1270return detectorCache;1271}12721273// For tests: allow injecting a detector implementation.1274export function setDetectorForTesting(impl) {1275detectorCache = impl;1276}12771278// ────────────────────────────────────────────────────────────────────────1279// Nudge/steer messages for the no-silent-fires policy.1280//1281// The hook is designed to be a conversational presence: every fire that1282// actually scans a file emits a developer-role message into the model's1283// next turn. Three states map to three templates:1284//1285// 1. **Fresh findings** → `renderTemplate` (existing, imperative).1286// 2. **Pending findings** → `renderPendingAck` (re-nudge for issues the1287// model was already told about in this1288// session but hasn't fixed yet).1289// 3. **Truly clean** → `renderCleanAck` (short positive nudge that1290// keeps the design discipline in context).1291//1292// All three are short (≤ ~40 tokens each) so the cumulative cost stays1293// bounded across a long active editing session. Users who explicitly want1294// silence-on-clean can set `IMPECCABLE_HOOK_QUIET=1` — runHook checks that1295// env before emitting #2 or #3.1296//1297// Why not stay silent on dedup-clean? Earlier versions did. The model1298// quickly forgets the prior reminder once tool output scrolls past it, so1299// re-nudging on the same file with a short "still pending" line keeps the1300// pressure on. The wording deliberately points back to "earlier this1301// session" so the model knows it's a re-mind, not a new finding.1302// ────────────────────────────────────────────────────────────────────────13031304const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';13051306export function renderCleanAck(filePath, opts = {}) {1307const cwd = opts.cwd || process.cwd();1308const display = relativize(filePath, cwd);1309return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;1310}13111312export function renderPendingAck(filePath, knownFindings, opts = {}) {1313const cwd = opts.cwd || process.cwd();1314const display = relativize(filePath, cwd);1315const count = knownFindings.length;1316// `knownFindings` here are the cache strings like "side-tab:3".1317const sample = knownFindings.slice(0, 3).join(', ');1318const more = count > 3 ? `, +${count - 3} more` : '';1319return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;1320}13211322export function shouldEmitAckForFile(filePath) {1323return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());1324}13251326export function designSystemOptions(config, detector, projectCwd) {1327if (config?.designSystem?.enabled === false) return {};1328if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {};1329try {1330const designSystem = detector.loadDesignSystemForCwd(projectCwd);1331return designSystem ? { designSystem } : {};1332} catch {1333return {};1334}1335}13361337export function appendDesignSystemNote(text, scanOptions) {1338if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;1339return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;1340}13411342// The directive footer is the part of the hook output that steers model1343// behavior. Three intentional moves:1344// 1. **Imperative, not advisory.** "Handle these..." beats "Consider1345// revising..." which the model treats as a soft suggestion it can1346// override when the user asked for any kind of throwaway / demo UI.1347// 2. **Explicit judgment clause.** Without it, the model will try to1348// "fix" intentional motion, bad fixtures, anti-pattern examples in1349// docs, or test cases. Naming the judgment inline beats hoping the1350// model infers it from context.1351// 3. **Acknowledgement instruction.** Hook output is injected as1352// developer-role context, not a chat turn, so the user never sees the1353// raw envelope. Asking the model to surface the resolution in its1354// reply is the cheapest way to make the feedback loop visible.1355function directiveFooter(display, opts = {}) {1356const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;1357const fileIgnoreGuidance = opts.grouped1358? 'run `/impeccable hooks ignore-file <path>` for the specific file'1359: `run \`${ignoreFileCommand}\``;1360return [1361'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',1362'',1363'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',1364'',1365`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,1366].join('\n');1367}13681369/**1370* Run the hook with explicit dependencies. Returns a result object:1371* { exitCode, stdout, audit, reason? }1372*1373* Never throws. All errors are converted to `exitCode: 0` + audit entry.1374*/1375export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {1376const audit = { ts: new Date(now()).toISOString(), event: 'PostToolUse' };1377const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });13781379try {1380// Re-entrancy guard.1381if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {1382return result({ reentrant: true, durationMs: 0 });1383}13841385if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {1386return result({ skipped: 'env-disabled', durationMs: 0 });1387}13881389const started = Date.now();13901391let event;1392try {1393event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;1394} catch {1395return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });1396}1397if (!event || typeof event !== 'object') {1398return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });1399}14001401const harness = resolveHarness(env, event);1402event = normalizeHookEvent(event, cwd, harness);1403audit.harness = harness;14041405const projectCwd = event.cwd || cwd;1406audit.cwd = projectCwd;1407const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);1408const primaryFileSet = new Set(primaryFiles);1409const targetFiles = expandScanTargets(primaryFiles, projectCwd);1410audit.session = event.session_id || null;1411if (event.tool_name) audit.tool = event.tool_name;14121413if (targetFiles.length === 0) {1414return result({ skipped: 'no-file-path', durationMs: Date.now() - started });1415}14161417const config = readConfig(projectCwd);1418if (config.enabled === false) {1419return result({ skipped: 'config-disabled', durationMs: Date.now() - started });1420}14211422const cache = readCache(projectCwd);1423const sessionId = event.session_id || 'unknown';1424const det = detector || await loadDetector();1425if (!det || typeof det.detectText !== 'function') {1426persistCache(projectCwd, cache);1427return result({ skipped: 'detector-missing', durationMs: Date.now() - started });1428}1429const scanOptions = designSystemOptions(config, det, projectCwd);14301431let pendingWinner = null;1432let cleanWinner = null;1433const freshGroups = [];1434let suppressionWinner = null;1435let detectorThrewAny = false;1436let lastSkip = 'no-scannable-file';1437let suppressedHit = false;14381439for (const filePath of targetFiles) {1440audit.file = filePath;14411442if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) {1443lastSkip = 'sensitive';1444continue;1445}1446if (GENERATED_PATH.test(filePath)) {1447lastSkip = 'generated';1448continue;1449}14501451const ext = path.extname(filePath).toLowerCase();1452audit.ext = ext;1453if (!ALLOWED_EXTS.has(ext)) {1454lastSkip = 'extension';1455continue;1456}14571458const relForMatch = relativize(filePath, projectCwd);1459if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {1460lastSkip = 'config-ignore-file';1461continue;1462}1463if (!fs.existsSync(filePath)) {1464lastSkip = 'file-missing';1465continue;1466}14671468if (primaryFileSet.has(filePath)) {1469const editCount = bumpEditCount(cache, sessionId, filePath);1470audit.editCount = editCount;14711472if (editCount > EDIT_COUNT_THRESHOLD) {1473const wasJustCrossed = editCount === EDIT_COUNT_THRESHOLD + 1;1474if (wasJustCrossed && !suppressionWinner) {1475suppressionWinner = { filePath };1476}1477lastSkip = 'suppressed';1478suppressedHit = true;1479continue;1480}1481}14821483const content = fs.readFileSync(filePath, 'utf-8');1484let findings;1485let detectorThrew = false;1486if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {1487try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }1488} else {1489try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }1490}14911492const filtered = filterFindings(findings || [], content, ext, config);1493const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);1494audit.findings = (findings || []).length;1495audit.freshFindings = fresh.length;14961497if (fresh.length > 0) {1498rememberFindings(cache, sessionId, filePath, fresh);1499freshGroups.push({ filePath, findings: fresh });1500continue;1501}15021503if (detectorThrew) {1504detectorThrewAny = true;1505continue;1506}15071508if (filtered.length > 0 && !pendingWinner) {1509const known = (ensureFile(cache, sessionId, filePath).findings || []).slice();1510pendingWinner = { filePath, known };1511} else if (filtered.length === 0 && !cleanWinner) {1512cleanWinner = { filePath };1513}1514}15151516persistCache(projectCwd, cache);15171518if (freshGroups.length > 0) {1519const firstGroup = freshGroups[0];1520const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);1521const allFindings = freshGroups.flatMap((group) => group.findings);1522return {1523exitCode: 0,1524stdout: payload(text, 'PostToolUse', harness),1525emission: {1526kind: 'fresh',1527file: firstGroup.filePath,1528findings: firstGroup.findings,1529groups: freshGroups,1530},1531audit: {1532...audit,1533file: firstGroup.filePath,1534emitted: true,1535freshFiles: freshGroups.length,1536freshFindings: allFindings.length,1537chars: text.length,1538durationMs: Date.now() - started,1539},1540};1541}15421543if (detectorThrewAny && !pendingWinner && !cleanWinner) {1544return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });1545}15461547if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {1548return result({ emitted: false, quiet: true, durationMs: Date.now() - started });1549}15501551if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {1552const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);1553return {1554exitCode: 0,1555stdout: payload(text, 'PostToolUse', harness),1556emission: { kind: 'pending', file: pendingWinner.filePath, known: pendingWinner.known },1557audit: {1558...audit,1559file: pendingWinner.filePath,1560emitted: true,1561kind: 'pending',1562pending: pendingWinner.known.length,1563chars: text.length,1564durationMs: Date.now() - started,1565},1566};1567}15681569if (suppressionWinner) {1570const text = suppressionNotice(relativize(suppressionWinner.filePath, projectCwd));1571return {1572exitCode: 0,1573stdout: payload(text, 'PostToolUse', harness),1574emission: { kind: 'suppression', file: suppressionWinner.filePath },1575audit: {1576...audit,1577file: suppressionWinner.filePath,1578suppressed: true,1579emitted: true,1580durationMs: Date.now() - started,1581},1582};1583}15841585if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {1586const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);1587return {1588exitCode: 0,1589stdout: payload(text, 'PostToolUse', harness),1590emission: { kind: 'clean', file: cleanWinner.filePath },1591audit: {1592...audit,1593file: cleanWinner.filePath,1594emitted: true,1595kind: 'clean',1596chars: text.length,1597durationMs: Date.now() - started,1598},1599};1600}16011602if (pendingWinner || cleanWinner) {1603return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });1604}16051606if (suppressedHit) {1607return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });1608}16091610return result({ skipped: lastSkip, durationMs: Date.now() - started });1611} catch (err) {1612return {1613exitCode: 0,1614stdout: '',1615audit: { ...audit, error: String(err && err.message ? err.message : err) },1616};1617}1618}16191620export function payload(text, eventName = 'PostToolUse', harness = 'claude') {1621if (harness === 'cursor') {1622return JSON.stringify({ additional_context: text });1623}1624// GitHub Copilot's postToolUse hook injects context via a top-level1625// `additionalContext` string (alongside an optional `modifiedResult`).1626if (harness === 'github') {1627return JSON.stringify({ additionalContext: text });1628}1629return JSON.stringify({1630hookSpecificOutput: { hookEventName: eventName, additionalContext: text },1631});1632}1633