cf99e7556c
- 更新 CI/CD 子域名反向代理配置 - 调整 Nginx 静态文件服务器配置 - 更新 CONTEXT.md 领域共享语言文档 - 添加 CLAUDE.md 代理工作指南 - 更新 Playwright E2E 测试配置
1679 lines
61 KiB
JavaScript
1679 lines
61 KiB
JavaScript
/**
|
|
* Shared library for the Impeccable design hook.
|
|
*
|
|
* Pure-ish helpers split out from `hook.mjs` so unit tests can exercise
|
|
* config parsing, finding filtering, dedup, render, and cache logic without
|
|
* spawning a subprocess. `hook.mjs` itself is the thin stdin/stdout shim.
|
|
*
|
|
* Public surface (everything exported is part of the contract):
|
|
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
|
|
* truthy(value)
|
|
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
|
|
* normalizeIgnoreValue(value)
|
|
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
|
|
* bumpEditCount(cache, sessionId, filePath) -> number
|
|
* suppressionNotice(filePath)
|
|
* filterFindings(findings, content, ext, config)
|
|
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
|
* renderTemplate(findings, filePath, config, opts)
|
|
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
|
* shouldEmitAckForFile(filePath)
|
|
* writeAuditLog(env, entry)
|
|
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
|
* matchesAnyGlob(filePath, globs)
|
|
* normalizeScanTargets(primaryTargets, projectCwd)
|
|
* runHook(deps) -> { exitCode, stdout, audit, reason? }
|
|
*
|
|
* Design notes:
|
|
* - All errors are swallowed at the runHook seam. The detector throwing must
|
|
* never break a turn. See PRD §5 "Failure modes".
|
|
* - Cache shape is JSON-friendly; we gc the oldest sessions when there are
|
|
* more than 8 to keep file size predictable across long-lived projects.
|
|
* - The detector loader looks for `detector/detect-antipatterns.mjs` next to
|
|
* this file first (built skill layout) and falls back to the repo root's
|
|
* `cli/engine/detect-antipatterns.mjs` (running from source).
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
export const ENVELOPE_PREFIX = '[impeccable@1]';
|
|
|
|
export const ALLOWED_EXTS = new Set([
|
|
'.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
|
|
'.css', '.scss', '.sass', '.less', '.ts', '.js',
|
|
]);
|
|
|
|
export const ACK_EXTS = new Set([
|
|
'.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
|
|
'.css', '.scss', '.sass', '.less',
|
|
]);
|
|
|
|
// Hard-skip regex for sensitive files. Cannot be turned off via config.
|
|
// Match tokenized secret/credential filenames, not UI names such as
|
|
// CredentialForm.tsx, SecretPage.jsx, or secretary-dashboard.vue.
|
|
export const SENSITIVE_PATH = new RegExp([
|
|
String.raw`(?:^|[/\\])\.env(?:\.|$)`,
|
|
String.raw`(?:^|[/\\])\.git(?:[/\\]|$)`,
|
|
String.raw`(?:^|[/\\])id_rsa(?:$|[._-])[^/\\]*$`,
|
|
String.raw`(?:^|[/\\])[^/\\]*\.pem$`,
|
|
String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?(?:secret|secrets|credential|credentials)(?=[._-])[^/\\]*\.(?:json|ya?ml|toml|ini|conf|config|env|txt|key|cert|crt|pem|js|ts)$`,
|
|
].join('|'), 'i');
|
|
|
|
// Hard-skip regex for generated, lock, minified, and build-output paths.
|
|
export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i;
|
|
|
|
export const TRUTHY = /^(1|true|yes|on)$/i;
|
|
|
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
enabled: true,
|
|
quiet: false,
|
|
auditLog: null,
|
|
designSystem: { enabled: true },
|
|
ignoreRules: [],
|
|
ignoreFiles: [],
|
|
ignoreValues: [],
|
|
limits: { maxFindings: 5, maxChars: 8000 },
|
|
});
|
|
|
|
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
|
|
'.impeccable/hook.cache.json',
|
|
'.impeccable/hook.pending.json',
|
|
'.impeccable/config.local.json',
|
|
]);
|
|
|
|
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
|
|
const HOOK_IGNORE_MARKER_CLOSE = '# impeccable-hook-ignore-end';
|
|
const CACHE_MAX_SESSIONS = 8;
|
|
export const EDIT_COUNT_THRESHOLD = 6;
|
|
|
|
export function truthy(value) {
|
|
return typeof value === 'string' && TRUTHY.test(value);
|
|
}
|
|
|
|
function depthIsSet(value) {
|
|
if (value === undefined || value === null) return false;
|
|
const text = String(value).trim();
|
|
if (!text) return false;
|
|
if (TRUTHY.test(text)) return true;
|
|
return /^\d+$/.test(text) && Number(text) > 0;
|
|
}
|
|
|
|
function safeReadJson(filePath) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getConfigPath(cwd) {
|
|
return path.join(cwd, '.impeccable', 'config.json');
|
|
}
|
|
|
|
export function getLocalConfigPath(cwd) {
|
|
return path.join(cwd, '.impeccable', 'config.local.json');
|
|
}
|
|
|
|
export function getCachePath(cwd) {
|
|
return path.join(cwd, '.impeccable', 'hook.cache.json');
|
|
}
|
|
|
|
export function getPendingPath(cwd) {
|
|
return path.join(cwd, '.impeccable', 'hook.pending.json');
|
|
}
|
|
|
|
export function resolveProjectCwd(event, fallback = process.cwd()) {
|
|
return event?.cwd
|
|
|| (Array.isArray(event?.workspace_roots) && event.workspace_roots[0])
|
|
|| envProjectDir(fallback)
|
|
|| fallback;
|
|
}
|
|
|
|
function looksLikeProjectRoot(dir) {
|
|
return ['.git', 'package.json', '.impeccable'].some((marker) => {
|
|
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
|
|
});
|
|
}
|
|
|
|
// Where `.impeccable/` (cache + config) lives for this event. Normally the
|
|
// session cwd, untouched. But when the agent was launched from an umbrella
|
|
// directory that is not itself a project (no .git, package.json, or
|
|
// .impeccable), key to the edited file's nearest project root instead, so a
|
|
// multi-project launch dir doesn't accumulate a shared cross-project cache
|
|
// (issue #305). Climbing stops at the home dir, falling back to the session
|
|
// cwd when no marker is found.
|
|
export function resolveCacheCwd(primaryFile, sessionCwd) {
|
|
const base = path.resolve(sessionCwd || process.cwd());
|
|
if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
|
|
if (looksLikeProjectRoot(base)) return base;
|
|
let dir;
|
|
try {
|
|
dir = path.dirname(path.resolve(primaryFile));
|
|
} catch {
|
|
return base;
|
|
}
|
|
const home = path.resolve(os.homedir());
|
|
while (true) {
|
|
if (dir === home) return base;
|
|
if (looksLikeProjectRoot(dir)) return dir;
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) return base;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
export function readConfig(cwd) {
|
|
const config = cloneDefaultConfig();
|
|
// Hook runtime settings live under `hook`; detector filters live under
|
|
// `detector`. Back-compat: older configs stored detector filters in `hook`,
|
|
// so read those first and let canonical `detector` settings win.
|
|
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
|
|
const raw = safeReadJson(filePath);
|
|
applyConfigSource(config, hookSection(raw));
|
|
applyDetectorConfigSource(config, detectorSection(raw));
|
|
}
|
|
return config;
|
|
}
|
|
|
|
// The hook settings subtree of a unified config.json / config.local.json.
|
|
function hookSection(raw) {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
|
}
|
|
|
|
function detectorSection(raw) {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
|
}
|
|
|
|
function numberOr(value, fallback) {
|
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
}
|
|
|
|
function cloneDefaultConfig() {
|
|
return {
|
|
...DEFAULT_CONFIG,
|
|
ignoreRules: [],
|
|
ignoreFiles: [],
|
|
ignoreValues: [],
|
|
designSystem: { ...DEFAULT_CONFIG.designSystem },
|
|
limits: { ...DEFAULT_CONFIG.limits },
|
|
};
|
|
}
|
|
|
|
function applyDetectorConfigSource(config, raw) {
|
|
if (!raw || typeof raw !== 'object') return config;
|
|
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
|
config.designSystem = {
|
|
...config.designSystem,
|
|
enabled: raw.designSystem.enabled === false ? false : true,
|
|
};
|
|
}
|
|
if (Array.isArray(raw.ignoreRules)) {
|
|
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
|
}
|
|
if (Array.isArray(raw.ignoreFiles)) {
|
|
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
|
}
|
|
if (Array.isArray(raw.ignoreValues)) {
|
|
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function applyConfigSource(config, raw) {
|
|
if (!raw || typeof raw !== 'object') return config;
|
|
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
|
|
config.enabled = raw.enabled === false ? false : true;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
|
|
config.quiet = raw.quiet === true;
|
|
}
|
|
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
|
|
config.auditLog = raw.auditLog.trim();
|
|
}
|
|
applyDetectorConfigSource(config, raw);
|
|
if (raw.limits && typeof raw.limits === 'object') {
|
|
config.limits = {
|
|
maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
|
|
maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
|
|
};
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return Array.from(new Set(values.map(String)));
|
|
}
|
|
|
|
export function normalizeIgnoreValue(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function normalizeIgnoreRule(rule) {
|
|
return String(rule || '').trim().toLowerCase();
|
|
}
|
|
|
|
function colorIgnoreKey(value) {
|
|
const color = parseIgnoreColor(value);
|
|
if (!color) return '';
|
|
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
|
}
|
|
|
|
function parseIgnoreColor(value) {
|
|
const text = String(value || '').trim().toLowerCase();
|
|
if (!text) return null;
|
|
|
|
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
|
if (hex) return parseHexIgnoreColor(hex[1]);
|
|
|
|
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
|
if (rgb) {
|
|
const parts = splitColorArgs(rgb[1]);
|
|
if (parts.length < 3 || parts.length > 4) return null;
|
|
const r = parseRgbChannel(parts[0]);
|
|
const g = parseRgbChannel(parts[1]);
|
|
const b = parseRgbChannel(parts[2]);
|
|
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
|
if ([r, g, b, a].some((v) => v === null)) return null;
|
|
return { r, g, b, a };
|
|
}
|
|
|
|
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
|
if (hsl) {
|
|
const parts = splitColorArgs(hsl[1]);
|
|
if (parts.length < 3 || parts.length > 4) return null;
|
|
const h = parseHueChannel(parts[0]);
|
|
const s = parsePercentChannel(parts[1]);
|
|
const l = parsePercentChannel(parts[2]);
|
|
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
|
if ([h, s, l, a].some((v) => v === null)) return null;
|
|
return hslToRgb(h, s, l, a);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseHexIgnoreColor(hex) {
|
|
if (hex.length === 3 || hex.length === 4) {
|
|
const r = parseInt(hex[0] + hex[0], 16);
|
|
const g = parseInt(hex[1] + hex[1], 16);
|
|
const b = parseInt(hex[2] + hex[2], 16);
|
|
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
|
return { r, g, b, a };
|
|
}
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
|
return { r, g, b, a };
|
|
}
|
|
|
|
function splitColorArgs(body) {
|
|
const text = String(body || '').trim();
|
|
if (!text) return [];
|
|
if (text.includes(',')) {
|
|
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
|
const last = parts[parts.length - 1];
|
|
if (last && last.includes('/')) {
|
|
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
|
return [...parts.slice(0, -1), ...split];
|
|
}
|
|
return parts;
|
|
}
|
|
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
|
}
|
|
|
|
function parseRgbChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
const scaled = match[2] ? value * 2.55 : value;
|
|
if (scaled < 0 || scaled > 255) return null;
|
|
return Math.round(scaled);
|
|
}
|
|
|
|
function parseAlphaChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
const alpha = match[2] ? value / 100 : value;
|
|
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
|
}
|
|
|
|
function parseHueChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
const unit = match[2] || 'deg';
|
|
if (unit === 'turn') return value * 360;
|
|
if (unit === 'rad') return value * (180 / Math.PI);
|
|
if (unit === 'grad') return value * 0.9;
|
|
return value;
|
|
}
|
|
|
|
function parsePercentChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
return value >= 0 && value <= 100 ? value / 100 : null;
|
|
}
|
|
|
|
function hslToRgb(hue, saturation, lightness, alpha) {
|
|
const h = (((hue % 360) + 360) % 360) / 360;
|
|
if (saturation === 0) {
|
|
const gray = clampByte(Math.round(lightness * 255));
|
|
return { r: gray, g: gray, b: gray, a: alpha };
|
|
}
|
|
const q = lightness < 0.5
|
|
? lightness * (1 + saturation)
|
|
: lightness + saturation - lightness * saturation;
|
|
const p = 2 * lightness - q;
|
|
const toRgb = (t) => {
|
|
let channel = t;
|
|
if (channel < 0) channel += 1;
|
|
if (channel > 1) channel -= 1;
|
|
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
|
if (channel < 1 / 2) return q;
|
|
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
|
return p;
|
|
};
|
|
return {
|
|
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
|
g: clampByte(Math.round(toRgb(h) * 255)),
|
|
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
|
a: alpha,
|
|
};
|
|
}
|
|
|
|
function clampByte(value) {
|
|
return Math.min(255, Math.max(0, value));
|
|
}
|
|
|
|
function ignoreValueMatches(rule, entryValue, findingValue) {
|
|
if (entryValue === findingValue) return true;
|
|
if (rule !== 'design-system-color') return false;
|
|
const entryColor = colorIgnoreKey(entryValue);
|
|
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
|
}
|
|
|
|
export function normalizeIgnoreValueEntries(entries) {
|
|
if (!Array.isArray(entries)) return [];
|
|
const out = [];
|
|
for (const entry of entries) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const rule = normalizeIgnoreRule(entry.rule);
|
|
const value = normalizeIgnoreValue(entry.value);
|
|
if (!rule || !value) continue;
|
|
const normalized = { rule, value };
|
|
const files = uniqueStrings([
|
|
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
|
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
|
]);
|
|
if (files.length > 0) normalized.files = files;
|
|
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
|
normalized.reason = entry.reason.trim();
|
|
}
|
|
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
|
normalized.createdAt = entry.createdAt.trim();
|
|
}
|
|
out.push(normalized);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function mergeIgnoreValues(existing, incoming) {
|
|
const map = new Map();
|
|
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
|
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
|
}
|
|
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
|
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
|
}
|
|
return Array.from(map.values());
|
|
}
|
|
|
|
function ignoreValueFilesKey(files) {
|
|
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
|
}
|
|
|
|
export function readCache(cwd) {
|
|
const raw = safeReadJson(getCachePath(cwd));
|
|
if (!raw || typeof raw !== 'object' || raw.version !== 1) {
|
|
return { version: 1, sessions: {} };
|
|
}
|
|
return {
|
|
version: 1,
|
|
sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
|
|
};
|
|
}
|
|
|
|
export function persistCache(cwd, cache) {
|
|
const sessions = cache.sessions || {};
|
|
const ids = Object.keys(sessions);
|
|
if (ids.length > CACHE_MAX_SESSIONS) {
|
|
// Garbage-collect oldest sessions by updatedAt.
|
|
const ordered = ids
|
|
.map((id) => [id, sessions[id]?.updatedAt || 0])
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, CACHE_MAX_SESSIONS);
|
|
const next = {};
|
|
for (const [id] of ordered) next[id] = sessions[id];
|
|
cache = { ...cache, sessions: next };
|
|
}
|
|
const target = getCachePath(cwd);
|
|
try {
|
|
ensureHookGitExcludes(cwd);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, JSON.stringify(cache));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function ensureHookGitExcludes(cwd = process.cwd()) {
|
|
try {
|
|
const target = resolveHookGitExcludeTarget(cwd);
|
|
if (!target) {
|
|
return { mode: 'none', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
|
|
}
|
|
|
|
const patterns = target.patternPrefix
|
|
? HOOK_LOCAL_IGNORE_PATTERNS.map((pattern) => `${target.patternPrefix}/${pattern}`)
|
|
: [...HOOK_LOCAL_IGNORE_PATTERNS];
|
|
const markerSuffix = target.patternPrefix || '.';
|
|
const markerOpen = `${HOOK_IGNORE_MARKER_OPEN} ${markerSuffix}`;
|
|
const markerClose = `${HOOK_IGNORE_MARKER_CLOSE} ${markerSuffix}`;
|
|
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
|
|
const block = [markerOpen, ...patterns, markerClose].join('\n');
|
|
const markerRe = new RegExp(`${escapeRegExp(markerOpen)}[\\s\\S]*?${escapeRegExp(markerClose)}`);
|
|
|
|
let updated;
|
|
if (markerRe.test(existing)) {
|
|
updated = existing.replace(markerRe, block);
|
|
} else {
|
|
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
|
|
}
|
|
|
|
if (updated !== existing) {
|
|
fs.mkdirSync(path.dirname(target.path), { recursive: true });
|
|
fs.writeFileSync(target.path, updated, 'utf-8');
|
|
}
|
|
|
|
return {
|
|
mode: 'git-info-exclude',
|
|
file: path.relative(path.resolve(cwd), target.path).split(path.sep).join('/'),
|
|
changed: updated !== existing,
|
|
patterns,
|
|
};
|
|
} catch {
|
|
return { mode: 'error', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
|
|
}
|
|
}
|
|
|
|
function resolveHookGitExcludeTarget(cwd) {
|
|
const start = path.resolve(cwd);
|
|
let dir = start;
|
|
while (true) {
|
|
const dotGit = path.join(dir, '.git');
|
|
if (fs.existsSync(dotGit)) {
|
|
const gitDir = resolveGitDir(dotGit, dir);
|
|
if (!gitDir) return null;
|
|
const relPrefix = path.relative(dir, start).split(path.sep).join('/');
|
|
return {
|
|
path: path.join(gitDir, 'info', 'exclude'),
|
|
patternPrefix: relPrefix && relPrefix !== '.' ? relPrefix : '',
|
|
};
|
|
}
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) return null;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
function resolveGitDir(dotGit, worktreeDir) {
|
|
const stat = fs.statSync(dotGit);
|
|
if (stat.isDirectory()) return dotGit;
|
|
if (!stat.isFile()) return null;
|
|
|
|
const body = fs.readFileSync(dotGit, 'utf-8').trim();
|
|
const match = body.match(/^gitdir:\s*(.+)$/i);
|
|
if (!match) return null;
|
|
return path.isAbsolute(match[1]) ? match[1] : path.resolve(worktreeDir, match[1]);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function ensureSession(cache, sessionId) {
|
|
if (!cache.sessions[sessionId]) {
|
|
cache.sessions[sessionId] = { updatedAt: Date.now(), files: {} };
|
|
}
|
|
return cache.sessions[sessionId];
|
|
}
|
|
|
|
function ensureFile(cache, sessionId, filePath) {
|
|
const session = ensureSession(cache, sessionId);
|
|
if (!session.files[filePath]) {
|
|
session.files[filePath] = { editCount: 0, findings: [] };
|
|
}
|
|
return session.files[filePath];
|
|
}
|
|
|
|
export function bumpEditCount(cache, sessionId, filePath) {
|
|
const fileEntry = ensureFile(cache, sessionId, filePath);
|
|
fileEntry.editCount = (fileEntry.editCount || 0) + 1;
|
|
ensureSession(cache, sessionId).updatedAt = Date.now();
|
|
return fileEntry.editCount;
|
|
}
|
|
|
|
export function suppressionNotice(filePath) {
|
|
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
|
|
}
|
|
|
|
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
|
function globToRegex(glob) {
|
|
let re = '^';
|
|
let i = 0;
|
|
while (i < glob.length) {
|
|
const c = glob[i];
|
|
if (c === '*') {
|
|
if (glob[i + 1] === '*') {
|
|
re += '.*';
|
|
i += 2;
|
|
if (glob[i] === '/') i += 1;
|
|
} else {
|
|
re += '[^/]*';
|
|
i += 1;
|
|
}
|
|
} else if (c === '?') {
|
|
re += '[^/]';
|
|
i += 1;
|
|
} else if (c === '{') {
|
|
const end = glob.indexOf('}', i);
|
|
if (end === -1) { re += '\\{'; i += 1; continue; }
|
|
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
|
re += `(?:${parts.join('|')})`;
|
|
i = end + 1;
|
|
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
|
re += `\\${c}`;
|
|
i += 1;
|
|
} else {
|
|
re += c;
|
|
i += 1;
|
|
}
|
|
}
|
|
re += '$';
|
|
return new RegExp(re);
|
|
}
|
|
|
|
export function matchesAnyGlob(filePath, globs) {
|
|
if (!Array.isArray(globs) || globs.length === 0) return false;
|
|
const normalized = filePath.split(path.sep).join('/');
|
|
for (const glob of globs) {
|
|
try {
|
|
const re = globToRegex(String(glob));
|
|
if (re.test(normalized)) return true;
|
|
// Match against basename too for convenience: `*.generated.tsx` should
|
|
// catch `src/foo.generated.tsx` without requiring `**/`.
|
|
const base = normalized.split('/').pop();
|
|
if (re.test(base)) return true;
|
|
} catch {
|
|
/* malformed glob, skip */
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function filterFindings(findings, _content, _ext, config) {
|
|
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
|
const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
|
|
return findings.filter((f) => {
|
|
if (!f || typeof f !== 'object') return false;
|
|
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
|
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function isIgnoredFindingValue(finding, ignoreValues) {
|
|
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
const value = extractFindingIgnoreValue(finding);
|
|
if (!rule || !value) return false;
|
|
return ignoreValues.some((entry) => {
|
|
const wildcardValue = entry.value === '*';
|
|
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
|
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
|
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
|
});
|
|
}
|
|
|
|
function findingMatchesScopedIgnoreFile(finding, globs) {
|
|
const filePath = String(finding?.file || '').trim();
|
|
if (!filePath) return false;
|
|
if (matchesAnyGlob(filePath, globs)) return true;
|
|
|
|
const normalized = filePath.split(path.sep).join('/');
|
|
const parts = normalized.split('/').filter(Boolean);
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const suffix = parts.slice(i).join('/');
|
|
if (matchesAnyGlob(suffix, globs)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function extractFindingIgnoreValue(finding) {
|
|
if (!finding || typeof finding !== 'object') return '';
|
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
const directValueRules = new Set([
|
|
'overused-font',
|
|
'bounce-easing',
|
|
'design-system-font',
|
|
'design-system-color',
|
|
'design-system-radius',
|
|
]);
|
|
if (!directValueRules.has(rule)) return '';
|
|
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
|
}
|
|
|
|
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
|
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
|
if (direct) return direct;
|
|
|
|
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
|
for (const text of candidates) {
|
|
if (rule === 'bounce-easing') {
|
|
const motion = extractMotionIgnoreValue(text);
|
|
if (motion) return motion;
|
|
continue;
|
|
}
|
|
|
|
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
|
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
|
|
|
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
|
if (family) return cleanIgnoreValueDisplay(family[1]);
|
|
|
|
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
|
if (google) {
|
|
try {
|
|
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
|
} catch {
|
|
return cleanIgnoreValueDisplay(google[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function extractMotionIgnoreValue(text) {
|
|
const tailwind = text.match(/\banimate-bounce\b/i);
|
|
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
|
|
|
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
|
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
|
|
|
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
|
if (animation) {
|
|
const token = animation[1]
|
|
.split(/[,\s]+/)
|
|
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
if (token) return cleanIgnoreValueDisplay(token);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function cleanIgnoreValueDisplay(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\+/g, ' ')
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
|
|
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
const fileEntry = ensureFile(cache, sessionId, filePath);
|
|
const known = new Set(fileEntry.findings || []);
|
|
const fresh = [];
|
|
for (const f of findings) {
|
|
const key = findingCacheKey(f);
|
|
if (known.has(key)) continue;
|
|
known.add(key);
|
|
fresh.push(f);
|
|
}
|
|
return fresh;
|
|
}
|
|
|
|
export function rememberFindings(cache, sessionId, filePath, findings) {
|
|
const fileEntry = ensureFile(cache, sessionId, filePath);
|
|
const known = new Set(fileEntry.findings || []);
|
|
for (const f of findings) known.add(findingCacheKey(f));
|
|
fileEntry.findings = Array.from(known);
|
|
ensureSession(cache, sessionId).updatedAt = Date.now();
|
|
}
|
|
|
|
function findingCacheKey(finding) {
|
|
const line = finding?.line || 0;
|
|
const value = extractFindingIgnoreValue(finding);
|
|
if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`;
|
|
if (line > 0) return `${finding.antipattern}:${line}`;
|
|
if (value) return `${finding.antipattern}:0:${value}`;
|
|
const snippet = String(finding?.snippet || '').trim().slice(0, 80);
|
|
return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`;
|
|
}
|
|
|
|
export function renderTemplate(findings, filePath, config, opts = {}) {
|
|
if (!Array.isArray(findings) || findings.length === 0) return '';
|
|
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
|
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
|
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
|
|
|
const cwd = opts.cwd || process.cwd();
|
|
const display = relativize(filePath, cwd);
|
|
const total = findings.length;
|
|
const shown = findings.slice(0, cap);
|
|
const remaining = total - shown.length;
|
|
|
|
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
|
const lines = shown.map((f) => formatFindingLine(f));
|
|
const more = remaining > 0
|
|
? `... and ${remaining} more (see /impeccable audit).`
|
|
: null;
|
|
const footer = directiveFooter(display);
|
|
|
|
const blocks = [header, ...lines];
|
|
if (more) blocks.push(more);
|
|
blocks.push('');
|
|
blocks.push(footer);
|
|
let text = blocks.join('\n');
|
|
|
|
if (text.length > maxChars) {
|
|
text = clampToBudget(header, lines, more, footer, maxChars);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function renderGroupedTemplate(groups, config, opts = {}) {
|
|
const realGroups = groups.filter((group) => Array.isArray(group.findings) && group.findings.length > 0);
|
|
if (realGroups.length === 0) return '';
|
|
if (realGroups.length === 1) {
|
|
const [group] = realGroups;
|
|
return renderTemplate(group.findings, group.filePath, config, opts);
|
|
}
|
|
|
|
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
|
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
|
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
|
const cwd = opts.cwd || process.cwd();
|
|
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
|
|
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
|
|
const lines = [];
|
|
let shownCount = 0;
|
|
|
|
for (const group of realGroups) {
|
|
const display = relativize(group.filePath, cwd);
|
|
lines.push(`${display} (${group.findings.length} issue(s)):`);
|
|
const remainingCap = Math.max(0, cap - shownCount);
|
|
const shown = group.findings.slice(0, remainingCap);
|
|
for (const finding of shown) {
|
|
lines.push(formatFindingLine(finding));
|
|
}
|
|
shownCount += shown.length;
|
|
const hidden = group.findings.length - shown.length;
|
|
if (hidden > 0) {
|
|
lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
|
|
}
|
|
}
|
|
|
|
const footer = directiveFooter('the affected files', { grouped: true });
|
|
let text = [header, ...lines, '', footer].join('\n');
|
|
if (text.length > maxChars) {
|
|
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function clampGroupedToBudget(header, lines, footer, maxChars) {
|
|
const assemble = (linesArr, omitted) => [
|
|
header,
|
|
...linesArr,
|
|
...(omitted ? ['... and more (see /impeccable audit).'] : []),
|
|
'',
|
|
footer,
|
|
].join('\n');
|
|
|
|
let working = lines.slice();
|
|
let omitted = false;
|
|
let assembled = assemble(working, omitted);
|
|
while (assembled.length > maxChars && working.length > 1) {
|
|
working.pop();
|
|
omitted = true;
|
|
assembled = assemble(working, omitted);
|
|
}
|
|
if (assembled.length > maxChars) {
|
|
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
|
}
|
|
return assembled;
|
|
}
|
|
|
|
function clampToBudget(header, lines, more, footer, maxChars) {
|
|
const assemble = (linesArr, moreText) => {
|
|
const blocks = [header, ...linesArr];
|
|
if (moreText) blocks.push(moreText);
|
|
blocks.push('');
|
|
blocks.push(footer);
|
|
return blocks.join('\n');
|
|
};
|
|
|
|
let working = lines.slice();
|
|
let moreText = more;
|
|
let assembled = assemble(working, moreText);
|
|
while (assembled.length > maxChars && working.length > 1) {
|
|
working.pop();
|
|
moreText = '... and more (see /impeccable audit).';
|
|
assembled = assemble(working, moreText);
|
|
}
|
|
if (assembled.length > maxChars) {
|
|
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
|
}
|
|
return assembled;
|
|
}
|
|
|
|
function formatFindingLine(f) {
|
|
const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
|
|
const desc = (f.description || '').trim();
|
|
const name = (f.name || '').trim();
|
|
// Description from the registry already ends in punctuation; join with a
|
|
// single space. `name` may have a trailing period already, keep it clean.
|
|
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
|
const ignoreCommand = formatFindingIgnoreCommand(f);
|
|
const ignoreSegment = ignoreCommand
|
|
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
|
|
: '';
|
|
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function formatFindingIgnoreCommand(finding) {
|
|
if (!finding || typeof finding !== 'object') return '';
|
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
if (!rule) return '';
|
|
const normalizedValue = extractFindingIgnoreValue(finding);
|
|
if (!normalizedValue) return '';
|
|
const value = extractFindingIgnoreValueRaw(finding);
|
|
const valueArg = quoteCommandArg(value);
|
|
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
|
return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
|
}
|
|
|
|
function quoteCommandArg(value) {
|
|
const text = String(value || '').trim();
|
|
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
|
|
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
function relativize(filePath, cwd) {
|
|
try {
|
|
const rel = path.relative(cwd, filePath);
|
|
if (!rel || rel.startsWith('..')) return filePath;
|
|
return rel.split(path.sep).join('/');
|
|
} catch {
|
|
return filePath;
|
|
}
|
|
}
|
|
|
|
// Codex `apply_patch` exposes the raw patch in `tool_input.command`, not
|
|
// `tool_input.file_path`. Claude Code may send both; parse the patch body
|
|
// so we can scan the file(s) the tool actually touched.
|
|
// https://developers.openai.com/codex/hooks#posttooluse
|
|
const APPLY_PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
|
|
|
|
export function parseApplyPatchPaths(command, projectCwd) {
|
|
if (!command || typeof command !== 'string') return [];
|
|
const out = [];
|
|
for (const m of command.matchAll(APPLY_PATCH_FILE_RE)) {
|
|
let p = (m[1] || '').trim();
|
|
if (!p) continue;
|
|
if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function resolveTargetFiles(event, projectCwd) {
|
|
const ti = event?.tool_input;
|
|
const out = [];
|
|
const add = (filePath) => {
|
|
if (typeof filePath !== 'string' || !filePath) return;
|
|
if (!out.includes(filePath)) out.push(filePath);
|
|
};
|
|
|
|
if (event?.tool_name === 'apply_patch' && ti && typeof ti.command === 'string') {
|
|
for (const filePath of parseApplyPatchPaths(ti.command, projectCwd)) add(filePath);
|
|
}
|
|
if (ti && typeof ti.file_path === 'string' && ti.file_path) {
|
|
add(ti.file_path);
|
|
}
|
|
// Cursor Write / StrReplace use `path`, not `file_path`.
|
|
if (ti && typeof ti.path === 'string' && ti.path) {
|
|
add(ti.path);
|
|
}
|
|
if (typeof event?.file_path === 'string' && event.file_path) {
|
|
add(event.file_path);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function resolveHarness(env = {}, event = null) {
|
|
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
|
if (explicit === 'cursor') return 'cursor';
|
|
if (explicit === 'github') return 'github';
|
|
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
|
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
|
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
|
if (event && typeof event === 'object'
|
|
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
|
&& event.tool_name === undefined && event.tool_input === undefined) {
|
|
return 'github';
|
|
}
|
|
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
|
return 'claude';
|
|
}
|
|
|
|
// GitHub Copilot's postToolUse payload is
|
|
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
|
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
|
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
|
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
|
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
|
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
|
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
|
// ran, so only the path (not the proposed content) is needed here.
|
|
export function parseGitHubToolArgs(toolArgs) {
|
|
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
|
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(toolArgs);
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
|
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
|
// *** Begin Patch
|
|
// *** Add File: /abs/app.css
|
|
// +body { ... }
|
|
// *** End Patch
|
|
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
|
// JSON string with the path under `path`. Both must map onto the internal shape.
|
|
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
|
|
|
function looksLikeApplyPatch(rawArgs) {
|
|
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
|
// Guard against an edit/create payload whose edited *content* happens to
|
|
// contain patch markers: that payload is a JSON object string, whereas a real
|
|
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
|
// treat non-JSON-object strings as apply_patch so edit events still get their
|
|
// `path` extracted.
|
|
try {
|
|
const parsed = JSON.parse(rawArgs);
|
|
if (parsed && typeof parsed === 'object') return false;
|
|
} catch { /* not JSON → genuine raw patch */ }
|
|
return true;
|
|
}
|
|
|
|
function applyPatchText(rawArgs) {
|
|
if (typeof rawArgs === 'string') {
|
|
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
|
// Defensive: a future Copilot build might JSON-wrap the patch.
|
|
const parsed = parseGitHubToolArgs(rawArgs);
|
|
return parsed.patch || parsed.input || parsed.command || '';
|
|
}
|
|
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
|
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function normalizeGitHubEvent(event, projectCwd) {
|
|
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
|
const sessionId = event.sessionId || event.session_id || 'unknown';
|
|
const toolName = event.toolName || event.tool_name || null;
|
|
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
|
const rawArgs = event.toolArgs;
|
|
|
|
let normalizedToolName = toolName;
|
|
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
|
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
|
// tool_name is 'apply_patch', so normalize the name even if a future build
|
|
// sends the patch under a different tool label.
|
|
const patch = applyPatchText(rawArgs);
|
|
if (patch) {
|
|
toolInput.command = patch;
|
|
normalizedToolName = 'apply_patch';
|
|
}
|
|
} else {
|
|
const args = parseGitHubToolArgs(rawArgs);
|
|
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
|
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
|
}
|
|
|
|
return {
|
|
...event,
|
|
cwd,
|
|
session_id: sessionId,
|
|
tool_name: normalizedToolName,
|
|
tool_input: toolInput,
|
|
};
|
|
}
|
|
|
|
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
|
if (!event || typeof event !== 'object') return event;
|
|
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
|
if (harness !== 'cursor') return event;
|
|
|
|
const cwd = event.cwd
|
|
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
|
|| envProjectDir(projectCwd)
|
|
|| projectCwd;
|
|
const sessionId = event.session_id || event.conversation_id || 'unknown';
|
|
|
|
const ti = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
|
|
const filePath = ti.file_path || ti.path || event.file_path;
|
|
if (filePath) {
|
|
return {
|
|
...event,
|
|
cwd,
|
|
session_id: sessionId,
|
|
tool_input: { ...ti, file_path: filePath },
|
|
};
|
|
}
|
|
|
|
return { ...event, cwd, session_id: sessionId };
|
|
}
|
|
|
|
function envProjectDir(fallback) {
|
|
if (typeof process.env.CURSOR_PROJECT_DIR === 'string' && process.env.CURSOR_PROJECT_DIR) {
|
|
return process.env.CURSOR_PROJECT_DIR;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// UI components often keep slop in a sibling/co-located stylesheet while the
|
|
// JSX edit is what triggered PostToolUse. Scan those styles too so an App.jsx
|
|
// patch doesn't report "clean" while styles.css still has Inter/bounce/etc.
|
|
const UI_CODE_EXTS = new Set(['.jsx', '.tsx', '.vue', '.svelte', '.astro']);
|
|
const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
const CO_SCAN_STYLE_NAMES = [
|
|
'styles.css', 'styles.scss', 'styles.sass', 'styles.less',
|
|
'index.css', 'index.scss', 'index.sass', 'index.less',
|
|
'global.css', 'global.scss', 'global.sass', 'global.less',
|
|
'globals.css', 'globals.scss', 'globals.sass', 'globals.less',
|
|
];
|
|
const MAX_SCAN_TARGETS = 6;
|
|
|
|
const STATIC_STYLE_IMPORT_RE = /import\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]/gi;
|
|
|
|
function hasPathTraversal(filePath) {
|
|
return typeof filePath === 'string' && filePath.includes('..');
|
|
}
|
|
|
|
function isInsideProject(filePath, projectCwd) {
|
|
if (!filePath || !projectCwd || hasPathTraversal(filePath)) return false;
|
|
try {
|
|
const rel = path.relative(projectCwd, filePath);
|
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
|
if (!content || typeof content !== 'string') return [];
|
|
const dir = path.dirname(fromFile);
|
|
const out = [];
|
|
for (const m of content.matchAll(STATIC_STYLE_IMPORT_RE)) {
|
|
let p = (m[1] || '').trim();
|
|
if (!p) continue;
|
|
if (p.startsWith('.')) p = path.resolve(dir, p);
|
|
else if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
|
|
if (!isInsideProject(p, projectCwd)) continue;
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function coLocatedStylesheets(filePath) {
|
|
const dir = path.dirname(filePath);
|
|
const base = path.basename(filePath, path.extname(filePath));
|
|
const candidates = new Set([
|
|
path.join(dir, `${base}.css`),
|
|
path.join(dir, `${base}.module.css`),
|
|
path.join(dir, `${base}.scss`),
|
|
path.join(dir, `${base}.module.scss`),
|
|
path.join(dir, `${base}.sass`),
|
|
path.join(dir, `${base}.module.sass`),
|
|
path.join(dir, `${base}.less`),
|
|
path.join(dir, `${base}.module.less`),
|
|
]);
|
|
for (const name of CO_SCAN_STYLE_NAMES) {
|
|
candidates.add(path.join(dir, name));
|
|
}
|
|
return [...candidates].filter((p) => fs.existsSync(p));
|
|
}
|
|
|
|
export function normalizeScanTargets(primaryTargets, projectCwd) {
|
|
if (!Array.isArray(primaryTargets) || primaryTargets.length === 0) return [];
|
|
const ordered = [];
|
|
const seen = new Set();
|
|
const baseCwd = projectCwd || process.cwd();
|
|
const normalizeTarget = (p) => {
|
|
// Preserve literal `..` segments so downstream sensitive-path checks
|
|
// still fire. path.resolve would collapse `/foo/../etc/passwd`.
|
|
if (hasPathTraversal(p)) return p;
|
|
return path.isAbsolute(p) ? p : path.resolve(baseCwd, p);
|
|
};
|
|
const add = (p) => {
|
|
if (ordered.length >= MAX_SCAN_TARGETS) return;
|
|
const abs = normalizeTarget(p);
|
|
if (seen.has(abs)) return;
|
|
seen.add(abs);
|
|
ordered.push(abs);
|
|
return abs;
|
|
};
|
|
|
|
for (const p of primaryTargets) add(p);
|
|
return ordered;
|
|
}
|
|
|
|
export function expandScanTargets(primaryTargets, projectCwd) {
|
|
const ordered = normalizeScanTargets(primaryTargets, projectCwd);
|
|
if (ordered.length === 0) return [];
|
|
const seen = new Set(ordered);
|
|
const baseCwd = projectCwd || process.cwd();
|
|
const add = (p) => {
|
|
if (ordered.length >= MAX_SCAN_TARGETS) return;
|
|
const abs = hasPathTraversal(p) ? p : (path.isAbsolute(p) ? p : path.resolve(baseCwd, p));
|
|
if (seen.has(abs)) return;
|
|
seen.add(abs);
|
|
ordered.push(abs);
|
|
return abs;
|
|
};
|
|
|
|
const normalizedPrimaries = [];
|
|
for (const p of ordered) normalizedPrimaries.push(p);
|
|
|
|
for (const p of normalizedPrimaries) {
|
|
if (ordered.length >= MAX_SCAN_TARGETS) break;
|
|
if (!isInsideProject(p, baseCwd)) continue;
|
|
const ext = path.extname(p).toLowerCase();
|
|
if (STYLE_EXTS.has(ext) || !UI_CODE_EXTS.has(ext)) continue;
|
|
|
|
let content = '';
|
|
try { content = fs.readFileSync(p, 'utf-8'); } catch { /* unreadable primary */ }
|
|
|
|
for (const imp of parseStaticStyleImports(content, p, projectCwd)) {
|
|
add(imp);
|
|
if (ordered.length >= MAX_SCAN_TARGETS) break;
|
|
}
|
|
for (const col of coLocatedStylesheets(p)) {
|
|
add(col);
|
|
if (ordered.length >= MAX_SCAN_TARGETS) break;
|
|
}
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
export function writeAuditLog(env, entry, cwd = process.cwd()) {
|
|
// The event's project root (entry.cwd) when present, else the passed cwd. Both
|
|
// config reads and relative log paths resolve against this, since the hook
|
|
// process cwd can differ from the project being edited.
|
|
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
|
|
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
|
|
let target = env?.IMPECCABLE_HOOK_LOG;
|
|
if (!target || typeof target !== 'string') {
|
|
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
|
|
}
|
|
if (!target || typeof target !== 'string') return false;
|
|
try {
|
|
let expanded;
|
|
if (target.startsWith('~/')) {
|
|
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
|
|
} else if (path.isAbsolute(target)) {
|
|
expanded = target;
|
|
} else {
|
|
expanded = path.resolve(baseCwd, target);
|
|
}
|
|
fs.mkdirSync(path.dirname(expanded), { recursive: true });
|
|
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
|
|
fs.appendFileSync(expanded, line);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const DETECTOR_CANDIDATES = [
|
|
path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
|
|
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
|
|
path.join(__dirname, '..', '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
|
|
];
|
|
|
|
let detectorCache = null;
|
|
export async function loadDetector(candidates = DETECTOR_CANDIDATES) {
|
|
if (detectorCache) return detectorCache;
|
|
const found = candidates.find((c) => fs.existsSync(c));
|
|
if (!found) return null;
|
|
const mod = await import(pathToFileURL(found));
|
|
detectorCache = {
|
|
detectText: mod.detectText,
|
|
detectHtml: mod.detectHtml,
|
|
loadDesignSystemForCwd: mod.loadDesignSystemForCwd,
|
|
};
|
|
return detectorCache;
|
|
}
|
|
|
|
// For tests: allow injecting a detector implementation.
|
|
export function setDetectorForTesting(impl) {
|
|
detectorCache = impl;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Nudge/steer messages for the no-silent-fires policy.
|
|
//
|
|
// The hook is designed to be a conversational presence: every fire that
|
|
// actually scans a file emits a developer-role message into the model's
|
|
// next turn. Three states map to three templates:
|
|
//
|
|
// 1. **Fresh findings** → `renderTemplate` (existing, imperative).
|
|
// 2. **Pending findings** → `renderPendingAck` (re-nudge for issues the
|
|
// model was already told about in this
|
|
// session but hasn't fixed yet).
|
|
// 3. **Truly clean** → `renderCleanAck` (short positive nudge that
|
|
// keeps the design discipline in context).
|
|
//
|
|
// All three are short (≤ ~40 tokens each) so the cumulative cost stays
|
|
// bounded across a long active editing session. Users who explicitly want
|
|
// silence-on-clean can set `IMPECCABLE_HOOK_QUIET=1` — runHook checks that
|
|
// env before emitting #2 or #3.
|
|
//
|
|
// Why not stay silent on dedup-clean? Earlier versions did. The model
|
|
// quickly forgets the prior reminder once tool output scrolls past it, so
|
|
// re-nudging on the same file with a short "still pending" line keeps the
|
|
// pressure on. The wording deliberately points back to "earlier this
|
|
// session" so the model knows it's a re-mind, not a new finding.
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
|
|
|
export function renderCleanAck(filePath, opts = {}) {
|
|
const cwd = opts.cwd || process.cwd();
|
|
const display = relativize(filePath, cwd);
|
|
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
|
}
|
|
|
|
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
|
const cwd = opts.cwd || process.cwd();
|
|
const display = relativize(filePath, cwd);
|
|
const count = knownFindings.length;
|
|
// `knownFindings` here are the cache strings like "side-tab:3".
|
|
const sample = knownFindings.slice(0, 3).join(', ');
|
|
const more = count > 3 ? `, +${count - 3} more` : '';
|
|
return `${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.`;
|
|
}
|
|
|
|
export function shouldEmitAckForFile(filePath) {
|
|
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
|
|
}
|
|
|
|
export function designSystemOptions(config, detector, projectCwd) {
|
|
if (config?.designSystem?.enabled === false) return {};
|
|
if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {};
|
|
try {
|
|
const designSystem = detector.loadDesignSystemForCwd(projectCwd);
|
|
return designSystem ? { designSystem } : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function appendDesignSystemNote(text, scanOptions) {
|
|
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
|
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
|
|
}
|
|
|
|
// The directive footer is the part of the hook output that steers model
|
|
// behavior. Three intentional moves:
|
|
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
|
|
// revising..." which the model treats as a soft suggestion it can
|
|
// override when the user asked for any kind of throwaway / demo UI.
|
|
// 2. **Explicit judgment clause.** Without it, the model will try to
|
|
// "fix" intentional motion, bad fixtures, anti-pattern examples in
|
|
// docs, or test cases. Naming the judgment inline beats hoping the
|
|
// model infers it from context.
|
|
// 3. **Acknowledgement instruction.** Hook output is injected as
|
|
// developer-role context, not a chat turn, so the user never sees the
|
|
// raw envelope. Asking the model to surface the resolution in its
|
|
// reply is the cheapest way to make the feedback loop visible.
|
|
function directiveFooter(display, opts = {}) {
|
|
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
|
|
const fileIgnoreGuidance = opts.grouped
|
|
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
|
|
: `run \`${ignoreFileCommand}\``;
|
|
return [
|
|
'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.',
|
|
'',
|
|
'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.',
|
|
'',
|
|
`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.`,
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Run the hook with explicit dependencies. Returns a result object:
|
|
* { exitCode, stdout, audit, reason? }
|
|
*
|
|
* Never throws. All errors are converted to `exitCode: 0` + audit entry.
|
|
*/
|
|
export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
|
const audit = { ts: new Date(now()).toISOString(), event: 'PostToolUse' };
|
|
const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
|
|
|
|
try {
|
|
// Re-entrancy guard.
|
|
if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
|
|
return result({ reentrant: true, durationMs: 0 });
|
|
}
|
|
|
|
if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
|
|
return result({ skipped: 'env-disabled', durationMs: 0 });
|
|
}
|
|
|
|
const started = Date.now();
|
|
|
|
let event;
|
|
try {
|
|
event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
|
|
} catch {
|
|
return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
|
|
}
|
|
if (!event || typeof event !== 'object') {
|
|
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
|
}
|
|
|
|
const harness = resolveHarness(env, event);
|
|
event = normalizeHookEvent(event, cwd, harness);
|
|
audit.harness = harness;
|
|
|
|
const sessionCwd = event.cwd || cwd;
|
|
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
|
|
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
|
|
audit.cwd = projectCwd;
|
|
const primaryFileSet = new Set(primaryFiles);
|
|
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
|
|
audit.session = event.session_id || null;
|
|
if (event.tool_name) audit.tool = event.tool_name;
|
|
|
|
if (targetFiles.length === 0) {
|
|
return result({ skipped: 'no-file-path', durationMs: Date.now() - started });
|
|
}
|
|
|
|
const config = readConfig(projectCwd);
|
|
if (config.enabled === false) {
|
|
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
|
|
}
|
|
|
|
const cache = readCache(projectCwd);
|
|
const sessionId = event.session_id || 'unknown';
|
|
const det = detector || await loadDetector();
|
|
if (!det || typeof det.detectText !== 'function') {
|
|
// Cache is not mutated yet at this point; nothing to persist.
|
|
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
|
|
}
|
|
const scanOptions = designSystemOptions(config, det, projectCwd);
|
|
|
|
let pendingWinner = null;
|
|
let cleanWinner = null;
|
|
const freshGroups = [];
|
|
let suppressionWinner = null;
|
|
let detectorThrewAny = false;
|
|
let lastSkip = 'no-scannable-file';
|
|
let suppressedHit = false;
|
|
let cacheDirty = false;
|
|
|
|
for (const filePath of targetFiles) {
|
|
audit.file = filePath;
|
|
|
|
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) {
|
|
lastSkip = 'sensitive';
|
|
continue;
|
|
}
|
|
if (GENERATED_PATH.test(filePath)) {
|
|
lastSkip = 'generated';
|
|
continue;
|
|
}
|
|
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
audit.ext = ext;
|
|
if (!ALLOWED_EXTS.has(ext)) {
|
|
lastSkip = 'extension';
|
|
continue;
|
|
}
|
|
|
|
const relForMatch = relativize(filePath, projectCwd);
|
|
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
|
|
lastSkip = 'config-ignore-file';
|
|
continue;
|
|
}
|
|
if (!fs.existsSync(filePath)) {
|
|
lastSkip = 'file-missing';
|
|
continue;
|
|
}
|
|
|
|
if (primaryFileSet.has(filePath)) {
|
|
const editCount = bumpEditCount(cache, sessionId, filePath);
|
|
cacheDirty = true;
|
|
audit.editCount = editCount;
|
|
|
|
if (editCount > EDIT_COUNT_THRESHOLD) {
|
|
const wasJustCrossed = editCount === EDIT_COUNT_THRESHOLD + 1;
|
|
if (wasJustCrossed && !suppressionWinner) {
|
|
suppressionWinner = { filePath };
|
|
}
|
|
lastSkip = 'suppressed';
|
|
suppressedHit = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
let findings;
|
|
let detectorThrew = false;
|
|
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
|
|
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
|
} else {
|
|
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
|
}
|
|
|
|
const filtered = filterFindings(findings || [], content, ext, config);
|
|
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
|
audit.findings = (findings || []).length;
|
|
audit.freshFindings = fresh.length;
|
|
|
|
if (fresh.length > 0) {
|
|
rememberFindings(cache, sessionId, filePath, fresh);
|
|
cacheDirty = true;
|
|
freshGroups.push({ filePath, findings: fresh });
|
|
continue;
|
|
}
|
|
|
|
if (detectorThrew) {
|
|
detectorThrewAny = true;
|
|
continue;
|
|
}
|
|
|
|
if (filtered.length > 0 && !pendingWinner) {
|
|
const known = (ensureFile(cache, sessionId, filePath).findings || []).slice();
|
|
pendingWinner = { filePath, known };
|
|
} else if (filtered.length === 0 && !cleanWinner) {
|
|
cleanWinner = { filePath };
|
|
}
|
|
}
|
|
|
|
// Persist only when the write is earned: fresh findings justify creating
|
|
// `.impeccable/` (dedup and suppression need it), and an already-present
|
|
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
|
// clean UI edit in a project with no Impeccable footprint, must be a
|
|
// no-op on disk (issues #344, #305).
|
|
if (freshGroups.length > 0
|
|
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
|
persistCache(projectCwd, cache);
|
|
}
|
|
|
|
if (freshGroups.length > 0) {
|
|
const firstGroup = freshGroups[0];
|
|
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
|
const allFindings = freshGroups.flatMap((group) => group.findings);
|
|
return {
|
|
exitCode: 0,
|
|
stdout: payload(text, 'PostToolUse', harness),
|
|
emission: {
|
|
kind: 'fresh',
|
|
file: firstGroup.filePath,
|
|
findings: firstGroup.findings,
|
|
groups: freshGroups,
|
|
},
|
|
audit: {
|
|
...audit,
|
|
file: firstGroup.filePath,
|
|
emitted: true,
|
|
freshFiles: freshGroups.length,
|
|
freshFindings: allFindings.length,
|
|
chars: text.length,
|
|
durationMs: Date.now() - started,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
|
|
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
|
}
|
|
|
|
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
|
|
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
|
}
|
|
|
|
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
|
|
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
|
return {
|
|
exitCode: 0,
|
|
stdout: payload(text, 'PostToolUse', harness),
|
|
emission: { kind: 'pending', file: pendingWinner.filePath, known: pendingWinner.known },
|
|
audit: {
|
|
...audit,
|
|
file: pendingWinner.filePath,
|
|
emitted: true,
|
|
kind: 'pending',
|
|
pending: pendingWinner.known.length,
|
|
chars: text.length,
|
|
durationMs: Date.now() - started,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (suppressionWinner) {
|
|
const text = suppressionNotice(relativize(suppressionWinner.filePath, projectCwd));
|
|
return {
|
|
exitCode: 0,
|
|
stdout: payload(text, 'PostToolUse', harness),
|
|
emission: { kind: 'suppression', file: suppressionWinner.filePath },
|
|
audit: {
|
|
...audit,
|
|
file: suppressionWinner.filePath,
|
|
suppressed: true,
|
|
emitted: true,
|
|
durationMs: Date.now() - started,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
|
|
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
|
return {
|
|
exitCode: 0,
|
|
stdout: payload(text, 'PostToolUse', harness),
|
|
emission: { kind: 'clean', file: cleanWinner.filePath },
|
|
audit: {
|
|
...audit,
|
|
file: cleanWinner.filePath,
|
|
emitted: true,
|
|
kind: 'clean',
|
|
chars: text.length,
|
|
durationMs: Date.now() - started,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (pendingWinner || cleanWinner) {
|
|
return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
|
|
}
|
|
|
|
if (suppressedHit) {
|
|
return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });
|
|
}
|
|
|
|
return result({ skipped: lastSkip, durationMs: Date.now() - started });
|
|
} catch (err) {
|
|
return {
|
|
exitCode: 0,
|
|
stdout: '',
|
|
audit: { ...audit, error: String(err && err.message ? err.message : err) },
|
|
};
|
|
}
|
|
}
|
|
|
|
export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
|
if (harness === 'cursor') {
|
|
return JSON.stringify({ additional_context: text });
|
|
}
|
|
// GitHub Copilot's postToolUse hook injects context via a top-level
|
|
// `additionalContext` string (alongside an optional `modifiedResult`).
|
|
if (harness === 'github') {
|
|
return JSON.stringify({ additionalContext: text });
|
|
}
|
|
return JSON.stringify({
|
|
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
|
});
|
|
}
|