mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* feat(cli): interactive hook consent + unified .impeccable/config.json Make the design-hook install a conscious choice and unify scattered config into one file. Interactive consent - On an interactive `skills install`/`update`, the CLI explains what the hook does and offers to install it (default yes), then records the per-developer decision in the gitignored `.impeccable/config.local.json`, so it never re-asks. A recorded decision or an already-installed hook short-circuits; `-y`/non-TTY keeps the historical install-by-default behavior; `--no-hooks` is a one-off skip that records nothing. The trigger keys on "is the hook installed?" + "is there a recorded decision?", not a brittle version check. Unified config - `.impeccable/config.json` (shared) and `.impeccable/config.local.json` (gitignored) now hold all Impeccable settings: hook settings under a `hook` key, plus top-level `updateCheck`. `/impeccable hooks` writes the `hook` subtree, preserving siblings. The hook runtime reads `hook.quiet` and `hook.auditLog`; context boot reads `updateCheck`. The legacy `IMPECCABLE_HOOK_DISABLED|QUIET|LOG` and `IMPECCABLE_NO_UPDATE_CHECK` env vars still work and override config; docs now lead with config and treat env vars as a legacy note. - No backward compat for the pre-unification `hook.json`/`hook.local.json` (the hook shipped an hour ago; nothing in the wild uses it). This repo's own hook config is migrated to `.impeccable/config.json`. The CLI and skill scripts are separate trees, so a small CLI-side config module (cli/lib/impeccable-config.mjs) duplicates the config-path and .git/info/exclude handling; comments flag the duplication. Tests: new cli config unit test; skills-cli consent tests (declined skips, accepted installs, --no-hooks records nothing); hook.test.mjs back-compat removed and quiet/auditLog-from-config + gitexclude coverage added. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): preserve sibling config fields + resolve audit log from event cwd (Bugbot) Two Bugbot findings: - High: `/impeccable hooks` edits replaced the whole `hook` object with the merge-helper output, dropping fields those helpers don't manage — so an `ignore-value --local` could wipe the recorded install consent and make the CLI re-prompt. writeConfig now merges over the existing hook object, keeping consent/quiet/auditLog. - Medium: config-based audit logging resolved hook.auditLog from process.cwd(), which can differ from the hook event's project root (and Cursor's pre-edit hook passed no cwd). The hook now stamps the resolved project root on the audit entry, and writeAuditLog reads config from entry.cwd when present. Tests: a /impeccable hooks edit preserves consent + quiet; writeAuditLog resolves config auditLog from entry.cwd, not the fallback cwd. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): resolve a relative auditLog path against the project root (Bugbot) A relative hook.auditLog was read from the project root but written relative to the hook process cwd, so when those differ the log went to the wrong place. writeAuditLog now resolves a relative target (from env or config) against the same project root it reads config from. Absolute and ~/ paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix hook consent recovery and smoke config * Fix hook consent explainer for Cursor * Fix empty hook target consent --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1263 lines
45 KiB
JavaScript
1263 lines
45 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)
|
|
* 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 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,
|
|
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;
|
|
}
|
|
|
|
export function readConfig(cwd) {
|
|
const config = cloneDefaultConfig();
|
|
// Hook settings live under the `hook` key of config.json (shared) and
|
|
// config.local.json (per-developer, gitignored); local wins.
|
|
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
|
|
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
|
|
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 numberOr(value, fallback) {
|
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
}
|
|
|
|
function cloneDefaultConfig() {
|
|
return {
|
|
...DEFAULT_CONFIG,
|
|
ignoreRules: [],
|
|
ignoreFiles: [],
|
|
ignoreValues: [],
|
|
limits: { ...DEFAULT_CONFIG.limits },
|
|
};
|
|
}
|
|
|
|
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();
|
|
}
|
|
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);
|
|
}
|
|
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();
|
|
}
|
|
|
|
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 };
|
|
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}`, entry);
|
|
}
|
|
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
|
map.set(`${entry.rule}\0${entry.value}`, entry);
|
|
}
|
|
return Array.from(map.values());
|
|
}
|
|
|
|
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) => entry.rule === rule && entry.value === value);
|
|
}
|
|
|
|
export function extractFindingIgnoreValue(finding) {
|
|
if (!finding || typeof finding !== 'object') return '';
|
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
if (rule !== 'overused-font') return '';
|
|
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
|
|
}
|
|
|
|
function extractFindingIgnoreValueRaw(finding) {
|
|
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) {
|
|
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 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 = `${f.antipattern}:${f.line || 0}`;
|
|
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(`${f.antipattern}:${f.line || 0}`);
|
|
fileEntry.findings = Array.from(known);
|
|
ensureSession(cache, sessionId).updatedAt = Date.now();
|
|
}
|
|
|
|
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} Required design corrections 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} Required design corrections 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 === 'claude' || explicit === 'codex') return 'claude';
|
|
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
|
return 'claude';
|
|
}
|
|
|
|
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
|
if (!event || typeof event !== 'object' || 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 };
|
|
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 = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
|
|
|
export function renderCleanAck(filePath, opts = {}) {
|
|
const cwd = opts.cwd || process.cwd();
|
|
const display = relativize(filePath, cwd);
|
|
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${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} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
|
|
}
|
|
|
|
export function shouldEmitAckForFile(filePath) {
|
|
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
|
|
}
|
|
|
|
// The directive footer is the part of the hook output that steers model
|
|
// behavior. Three intentional moves:
|
|
// 1. **Imperative, not advisory.** "Fix 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 exception clause.** Without it, the model will try to
|
|
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
|
|
// test cases. Naming the exception 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 fix in its reply is
|
|
// the cheapest way to make the feedback loop visible to the user.
|
|
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 [
|
|
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
|
|
'',
|
|
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
|
|
'',
|
|
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. 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 projectCwd = event.cwd || cwd;
|
|
audit.cwd = projectCwd;
|
|
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), 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') {
|
|
persistCache(projectCwd, cache);
|
|
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
|
|
}
|
|
|
|
let pendingWinner = null;
|
|
let cleanWinner = null;
|
|
const freshGroups = [];
|
|
let suppressionWinner = null;
|
|
let detectorThrewAny = false;
|
|
let lastSkip = 'no-scannable-file';
|
|
let suppressedHit = 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);
|
|
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); } catch { findings = []; detectorThrew = true; }
|
|
} else {
|
|
try { findings = await det.detectText(content, filePath); } 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);
|
|
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 };
|
|
}
|
|
}
|
|
|
|
persistCache(projectCwd, cache);
|
|
|
|
if (freshGroups.length > 0) {
|
|
const firstGroup = freshGroups[0];
|
|
const text = renderGroupedTemplate(freshGroups, config, { cwd: projectCwd });
|
|
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 = renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd });
|
|
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 = renderCleanAck(cleanWinner.filePath, { cwd: projectCwd });
|
|
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 });
|
|
}
|
|
return JSON.stringify({
|
|
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
|
});
|
|
}
|