Files
pbakaus_impeccable/skill/scripts/hook-lib.mjs
T
672517f76e Add automatic design hook install and exceptions (#170)
* docs: add PRD for design detector hook integration

Plans a PostToolUse hook for Claude Code and Codex that runs the
existing design detector after every relevant file write and feeds
findings back to the agent as advisory system-reminder context. No
implementation in this commit; covers UX, technical design, build
pipeline changes, distribution, coverage tradeoffs, and rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: revise hook PRD with best-practices review

Folds in the P0/P1/P2 findings from an online best-practices critique
against the official Claude Code and Codex hook references plus 10+
2026 community guides and similar prior-art tools (claw-hooks,
claude-code-hooks-mastery).

Key changes:
- Exec form everywhere (Codex snippet was shell form), with Windows
  rationale.
- Default timeout dropped from 10s to 5s.
- Re-entrancy guard (CLAUDE_HOOK_DEPTH) and per-file edit counter.
- Session-scoped finding dedup promoted from open question to v1.
- Per-language inline-ignore syntax map (HTML/JSX/CSS/JS).
- Hard-skip rules for sensitive paths and generated/lock files.
- Honest framing about Claude Code lacking per-plugin hook disable.
- Honest framing about Bash-written files being invisible in v1.
- Codex Windows-not-supported call-out, feature flag note, trust ceremony detail.
- Optional NDJSON audit log via IMPECCABLE_HOOK_LOG.
- Findings cap lowered 8 → 5 with attention-budget rationale.
- Versioned envelope ([impeccable@1]) on rendered template.
- Expanded test plan, decision log, and stdin payload appendix.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(hooks): ship the design detector hook for Claude Code and Codex

Implements docs/hooks-prd.md: a PostToolUse hook that runs the
impeccable design detector after every Edit/Write/MultiEdit on a UI
file and pushes findings into the agent's next-turn context as a
short system reminder. Silent on clean files. Never blocks an edit.

Why this matters: today, design slop (side-tab borders, gradient
text, purple/cyan palettes, bounce easing, etc.) only gets caught
when a human notices or someone explicitly runs /impeccable audit.
The hook closes the loop at the moment slop is written.

What ships in v1
- skill/scripts/hook.mjs: PostToolUse entry. Reads stdin, runs the
  detector in-process (no `npx impeccable` cold start), emits
  hookSpecificOutput.additionalContext when fresh findings exist.
- skill/scripts/hook-lib.mjs: extracted helpers (config, cache,
  filter, render, audit log, runHook orchestrator). 100% unit-testable.
- skill/scripts/hook-session-start.mjs: SessionStart greeting,
  gated by a project-scannable probe + 30-day throttle.
- skill/scripts/hook-admin.mjs: backs /impeccable hooks
  on/off/status/ignore-rule/ignore-file/reset.

Hardening built in
- Re-entrancy guard (IMPECCABLE_HOOK_DEPTH) so the hook can never
  recursively spawn itself.
- Hard-skip regexes for sensitive paths (.env, .pem, id_rsa,
  secrets, credentials, .git) and generated/lock/build output. These
  fire before the file is even read; cannot be turned off via config.
- Path-traversal check on the inbound file_path.
- Session-scoped dedup keyed by (session, file, rule, line) so the
  same finding never lands in context twice. Prevents the ~12.5K
  wasted tokens per chatty session called out in the PRD.
- Per-(session, file) edit counter with a one-shot suppression
  notice on the 7th edit, silent after.
- Fail-open contract: every error path returns exit 0 with no
  stdout. Optional NDJSON audit log via IMPECCABLE_HOOK_LOG.

Three kill switches (precedence high to low):
1. IMPECCABLE_HOOK_DISABLED env var (1/true/yes/on, case-insensitive)
2. .impeccable/hook.json `enabled: false`
3. /impeccable hooks off slash command (writes the JSON)

Inline ignores are language-aware. `// impeccable: ignore <rule>` for
JS/TS, `<!-- impeccable: ignore <rule> -->` for HTML/Vue/Svelte/Astro,
`{/* impeccable: ignore <rule> */}` for JSX/TSX, `/* impeccable:
ignore <rule> */` for CSS. `*` matches any rule. Directive applies
to the next non-blank line. Same shape as ESLint, Stylelint, Biome.

Build pipeline
- scripts/lib/transformers/hooks.js: per-provider hooks.json
  builders, plus the slim .codex-plugin/plugin.json manifest.
- providers.js: emitHooks: 'claude' for claude-code, emitHooks:
  'codex' for codex and agents. Codex also emits emitCodexPlugin.
- factory.js: emits hooks/hooks.json next to the skills tree.
- build.js: syncs hooks/ into harness roots and into the slim
  plugin/ subtree; writes .codex-plugin/plugin.json. Build is
  idempotent (verified: 98 staged files unchanged across two runs).

Claude Code wiring uses exec form (command + args) and the
${CLAUDE_PLUGIN_ROOT} placeholder. Matcher: Edit|Write|MultiEdit.
`if:` glob filters to UI extensions before spawning Node. PostToolUse
timeout 5s, SessionStart timeout 3s.

Codex wiring uses ${PLUGIN_ROOT} (Codex's native placeholder),
matcher Edit|Write|apply_patch, no `if:` analog (the script does the
extension filter). macOS and Linux only; hooks are disabled on
Windows in current Codex builds. The trust ceremony and feature flag
are documented in README.md.

Routing
- /impeccable hooks lives outside the 23-command router table on
  purpose: it is plumbing, not a design skill. The hidden
  routing slot is added to SKILL.md alongside pin/unpin so the LLM
  knows to dispatch it. The 23-command count and all stale-count
  validators remain happy.

Tests
- tests/hook.test.mjs: 38 unit tests covering env parsing, config
  load + defaults + malformed, cache round-trip + GC,
  ignoreRules/minSeverity/inline ignores (all four languages),
  globbing with **/*/{a,b}, render template with cap + clamp + 0-line
  prefix drop, audit log NDJSON, payload event-name parameterization,
  re-entrancy, kill switches, sensitive-path + generated-path +
  traversal skips, allowlist filter, config ignoreFiles, edit
  counter cycle including the 7th-edit notice, MultiEdit and
  apply_patch payload shapes, detector throw swallow, malformed
  stdin, missing file race.
- tests/hook-build.test.mjs: 18 integration tests covering hook
  manifest shape (matcher, timeouts, exec form, if: glob, placeholders),
  Codex differences (${PLUGIN_ROOT}, no if:, no SessionStart),
  Codex plugin manifest (no inline hooks field to avoid the
  duplicate-file error), routing across the hooksJsonFor table, and
  presence of all three committed artifacts plus the bundled detector
  the runtime relative-import path depends on.

Full suite: 175 bun tests + 186 node tests, all green.

Docs
- README.md: new "Design hook" section explaining default behavior,
  per-project / global / inline disable paths, the JSON schema knobs,
  the audit log debug flag, and the slop / a11y coverage split.
- HARNESSES.md: flips the `hooks` row for Codex from No -> Yes
  (Claude was already Yes), adds a per-harness hook-surface table
  with the manifest location and matcher each provider uses.

Open questions from the PRD intentionally deferred to v2: Bash-write
blind spot, effort-aware suppression, Stop-hook session summary,
per-rule severity, async hook mode. None block v1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Codex hook scanning: apply_patch paths and co-located stylesheets

Parse file targets from Codex apply_patch command bodies, co-scan imported
and sibling CSS when UI components are edited, drop the git-sweep PostToolUse
group, and align Codex SessionStart manifest and trust docs with the official
hooks spec.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gitignore hook session cache and drop local test HTML

Hook dedup/throttle state in .impeccable/hook.cache.json is per-project
runtime data like other .impeccable/ sidecars. Remove an untracked
bad-nested-flexbox scratch page from site/public/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Claude Code hook: drop Edit-only if filter so Write/MultiEdit fire

Claude's if permission rule binds to one tool name, so Edit(*.{…}) never
spawned the hook on Write or MultiEdit despite the matcher listing them.
Extension filtering now lives in hook-lib on both Claude and Codex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Surface Cursor design findings via stop-hook followup

Replace dropped postToolUse additional_context with afterFileEdit recording
and a one-shot stop followup_message so anti-pattern nudges reach the agent.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix design hook packaging and scans

* Fix Cursor hook pending bucket fallback

* Fix Sass hook scan coverage

* Fix Cursor hook review findings

* Fix session start dead hook normalization

* Fix hook config and relative scan paths

* Remove SessionStart design hook

* Remove redundant afterFileEdit normalization

* Fix Cursor suppression and module style scans

* Fix sensitive path hook filter

* Fix disabled Cursor stop hook emission

* Refresh hook harness artifacts

* Fix Cursor hook manifest install

* Add hook ignore-value support

* Ignore hook runtime files locally

* Fix Codex plugin hook packaging

* fix: address PR review bot findings

Block numeric hook depth counters from re-entering.

Avoid following stylesheet imports from traversal-looking hook targets.

* fix: gate ignore-value suggestions by supported rules

Only render exact ignore-value commands when the same finding can be suppressed by ignoreValues.

* Package Codex plugin as hook-only

* Remove Codex plugin packaging

* Recover hook install probe plumbing

* Remove Codex hook packaging follow-up doc

* Remove extra hook docs and skill wording changes

* Install real design hooks via skills CLI

* Add provider hook smoke runner

* Fix Cursor hook delivery with preToolUse gate

* Simplify Cursor hook install to preToolUse

* Clarify confirmed hook exceptions

* Persist hook ignores in shared config

* Guard font hook exceptions

* Fix hook install after main rebase

* Fix hook scan target handling

* fix: address hook review findings

* Address hook review feedback

* Stabilize DeepSeek insert live fixture

* Fix Cursor hook Python shell write bypass

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:19:19 -07:00

1233 lines
44 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,
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/hook.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', 'hook.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.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();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
return config;
}
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 (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) {
const target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: 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;
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)) {
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 },
});
}