diff --git a/skill/reference/hooks.md b/skill/reference/hooks.md index a329dd833..b7f6033c3 100644 --- a/skill/reference/hooks.md +++ b/skill/reference/hooks.md @@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. +Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. + Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores. Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch. @@ -79,7 +81,7 @@ node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx" ## Constraints -- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. +- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched. - Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing. - Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders. - The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly. diff --git a/skill/scripts/hook-before-edit.mjs b/skill/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/skill/scripts/hook-before-edit.mjs +++ b/skill/scripts/hook-before-edit.mjs @@ -11,6 +11,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { @@ -22,6 +23,7 @@ import { designSystemOptions, filterFindings, loadDetector, + matchConfiguredExtension, matchesAnyGlob, persistCache, readCache, @@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) { } } +// The static HTML engine reads its input from disk, but preToolUse only has +// the proposed content. Stage it in a temp file so html-engine targets get the +// same DOM-structural rules pre-write that runHook applies post-edit. +async function detectProposedHtml(detector, content, filePath, scanOptions) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-')); + const tmpFile = path.join(dir, path.basename(filePath)); + try { + fs.writeFileSync(tmpFile, content); + const findings = await detector.detectHtml(tmpFile, scanOptions); + // Findings carry the temp path; remap so file-scoped ignores still match. + return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + function cursorBlockMessage(findings, filePath, config, cwd) { const rendered = renderTemplate(findings, filePath, config, { cwd }); const blocked = rendered.replace( @@ -398,9 +416,13 @@ async function main() { if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); + // Config is read before the extension gate so `detector.extensions` entries + // (e.g. `.blade.php` template files, issue #316) can widen it. + const config = readConfig(cwd); const ext = path.extname(filePath).toLowerCase(); - audit.ext = ext; - if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started }); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + audit.ext = configuredExt ? configuredExt.ext : ext; + if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started }); const contentResult = proposedContent(event, cwd, filePath); if (contentResult && typeof contentResult === 'object' && contentResult.skipped) { @@ -409,7 +431,6 @@ async function main() { const content = typeof contentResult === 'string' ? contentResult : ''; if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started }); - const config = readConfig(cwd); if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started }); const rel = relativePath(filePath, cwd); @@ -423,9 +444,16 @@ async function main() { } const scanOptions = designSystemOptions(config, detector, cwd); + // Mirror runHook's engine routing so template issues the HTML engine catches + // post-edit cannot slip past the pre-write gate. + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); let findings = []; try { - findings = await detector.detectText(content, filePath, scanOptions); + findings = useHtmlEngine && typeof detector.detectHtml === 'function' + ? await detectProposedHtml(detector, content, filePath, scanOptions) + : await detector.detectText(content, filePath, scanOptions); } catch { return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started }); } diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -14,10 +14,11 @@ * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) - * shouldEmitAckForFile(filePath) + * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> * matchesAnyGlob(filePath, globs) @@ -78,6 +79,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreRules: [], ignoreFiles: [], ignoreValues: [], + extensions: [], limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -202,6 +204,7 @@ function cloneDefaultConfig() { ignoreRules: [], ignoreFiles: [], ignoreValues: [], + extensions: [], designSystem: { ...DEFAULT_CONFIG.designSystem }, limits: { ...DEFAULT_CONFIG.limits }, }; @@ -224,9 +227,55 @@ function applyDetectorConfigSource(config, raw) { if (Array.isArray(raw.ignoreValues)) { config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues); } + if (Array.isArray(raw.extensions)) { + config.extensions = mergeExtensions(config.extensions, raw.extensions); + } return config; } +// Extra scanned extensions from `detector.extensions` config. Entries are +// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case +// for server-side templates) or bare strings as shorthand. Extensions are +// matched against the end of the filename, not path.extname, so double +// extensions like `.blade.php` and `.html.erb` work (issue #316). +function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { @@ -1353,8 +1402,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) { return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`; } -export function shouldEmitAckForFile(filePath) { - return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase()); +export function shouldEmitAckForFile(filePath, config = null) { + if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true; + // Configured html-engine extensions are declared UI markup, so they get the + // clean/pending acks; text-engine ones stay quiet like plain .ts/.js. + const configured = matchConfiguredExtension(filePath, config?.extensions); + return Boolean(configured && configured.engine === 'html'); } export function designSystemOptions(config, detector, projectCwd) { @@ -1485,8 +1538,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const ext = path.extname(filePath).toLowerCase(); - audit.ext = ext; - if (!ALLOWED_EXTS.has(ext)) { + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + audit.ext = configuredExt ? configuredExt.ext : ext; + if (!ALLOWED_EXTS.has(ext) && !configuredExt) { lastSkip = 'extension'; continue; } @@ -1520,7 +1574,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const content = fs.readFileSync(filePath, 'utf-8'); let findings; let detectorThrew = false; - if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') { + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; } } else { try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; } @@ -1594,7 +1651,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) { + if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); return { exitCode: 0, @@ -1628,7 +1685,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) { + if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) { const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); return { exitCode: 0, diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index ec3d9ec56..5b9e15cef 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -37,6 +37,7 @@ import { renderCleanAck, renderPendingAck, shouldEmitAckForFile, + matchConfiguredExtension, matchesAnyGlob, writeAuditLog, suppressionNotice, @@ -221,6 +222,49 @@ describe('readConfig()', () => { assert.equal(cfg.limits.maxFindings, 3); }); + it('parses detector.extensions entries and defaults engine to html', () => { + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ + detector: { + extensions: [ + { ext: '.blade.php' }, + { ext: '.html.erb', engine: 'html' }, + { ext: '.d.ts.hbs', engine: 'text' }, + 'twig', + { ext: '' }, + { engine: 'html' }, + 42, + ], + }, + })); + const cfg = readConfig(cwd); + assert.deepEqual(cfg.extensions, [ + { ext: '.blade.php', engine: 'html' }, + { ext: '.html.erb', engine: 'html' }, + { ext: '.d.ts.hbs', engine: 'text' }, + { ext: '.twig', engine: 'html' }, + ]); + }); + + it('lets local-config detector.extensions override the shared engine per ext', () => { + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ + detector: { extensions: [{ ext: '.blade.php', engine: 'html' }] }, + })); + fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ + detector: { extensions: [{ ext: '.blade.php', engine: 'text' }, { ext: '.twig' }] }, + })); + const cfg = readConfig(cwd); + assert.deepEqual(cfg.extensions, [ + { ext: '.blade.php', engine: 'text' }, + { ext: '.twig', engine: 'html' }, + ]); + }); + + it('defaults detector.extensions to an empty list', () => { + assert.deepEqual(readConfig(cwd).extensions, []); + }); + it('parses the new quiet and auditLog fields from the unified config', () => { fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ @@ -1482,6 +1526,75 @@ describe('ALLOWED_EXTS', () => { assert.equal(shouldEmitAckForFile(`/x/src/tool${ext}`), false); } }); + + it('acks configured html-engine extensions but not text-engine ones', () => { + const config = { + extensions: [ + { ext: '.blade.php', engine: 'html' }, + { ext: '.d.ts.hbs', engine: 'text' }, + ], + }; + assert.equal(shouldEmitAckForFile('/x/views/card.blade.php', config), true); + assert.equal(shouldEmitAckForFile('/x/templates/types.d.ts.hbs', config), false); + assert.equal(shouldEmitAckForFile('/x/views/card.blade.php'), false); + }); +}); + +describe('matchConfiguredExtension()', () => { + const extensions = [ + { ext: '.blade.php', engine: 'html' }, + { ext: '.html.erb', engine: 'html' }, + { ext: '.twig', engine: 'html' }, + ]; + + it('matches double extensions against the end of the filename', () => { + assert.deepEqual( + matchConfiguredExtension('/app/resources/views/Card.blade.php', extensions), + { ext: '.blade.php', engine: 'html' }, + ); + assert.deepEqual( + matchConfiguredExtension('app/views/users/show.html.erb', extensions), + { ext: '.html.erb', engine: 'html' }, + ); + assert.deepEqual( + matchConfiguredExtension('/templates/base.twig', extensions), + { ext: '.twig', engine: 'html' }, + ); + }); + + it('is case-insensitive on the filename', () => { + assert.ok(matchConfiguredExtension('/views/Card.BLADE.PHP', extensions)); + }); + + it('prefers the longest matching suffix regardless of config order', () => { + const overlapping = [ + { ext: '.php', engine: 'text' }, + { ext: '.blade.php', engine: 'html' }, + ]; + assert.deepEqual( + matchConfiguredExtension('/views/card.blade.php', overlapping), + { ext: '.blade.php', engine: 'html' }, + ); + assert.deepEqual( + matchConfiguredExtension('/views/card.blade.php', overlapping.slice().reverse()), + { ext: '.blade.php', engine: 'html' }, + ); + assert.deepEqual( + matchConfiguredExtension('/app/Controller.php', overlapping), + { ext: '.php', engine: 'text' }, + ); + }); + + it('does not match unrelated files or bare dotfile-like names', () => { + assert.equal(matchConfiguredExtension('/app/Http/Controller.php', extensions), null); + assert.equal(matchConfiguredExtension('/views/.blade.php', extensions), null); + assert.equal(matchConfiguredExtension('/src/Card.tsx', extensions), null); + }); + + it('returns null for empty or missing config', () => { + assert.equal(matchConfiguredExtension('/views/card.blade.php', []), null); + assert.equal(matchConfiguredExtension('/views/card.blade.php', undefined), null); + }); }); describe('renderCleanAck() / renderPendingAck()', () => { @@ -1900,6 +2013,91 @@ describe('runHook() — events without file_path', () => { }); }); +describe('runHook() — configured template extensions (issue #316)', () => { + let cwd; + beforeEach(() => { cwd = mkTmp(); }); + afterEach(() => fs.rmSync(cwd, { recursive: true, force: true })); + + function eventFor(file) { + return { + session_id: 'sid-ext', + cwd, + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: file }, + }; + } + + function writeFixture(rel, body) { + const abs = path.join(cwd, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + return abs; + } + + function writeExtensionsConfig(extensions) { + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { extensions } })); + } + + function recordingDetector(findings = []) { + const calls = { html: [], text: [] }; + return { + calls, + detectHtml: (filePath) => { calls.html.push(filePath); return findings; }, + detectText: (_content, filePath) => { calls.text.push(filePath); return findings; }, + }; + } + + it('skips .blade.php with no config — the issue #316 repro', async () => { + const file = writeFixture('resources/views/card.blade.php', '
Hi
'); + const det = recordingDetector([finding('gradient-text', 1)]); + const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); + assert.equal(r.stdout, ''); + assert.equal(r.audit.skipped, 'extension'); + assert.equal(det.calls.html.length + det.calls.text.length, 0); + }); + + it('scans a configured .blade.php through the html engine and emits findings', async () => { + writeExtensionsConfig([{ ext: '.blade.php' }]); + const file = writeFixture('resources/views/card.blade.php', '
Hi
'); + const det = recordingDetector([finding('gradient-text', 1, { name: 'Gradient text' })]); + const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); + assert.match(r.stdout, /Design hook findings requiring review/); + assert.match(r.stdout, /gradient-text/); + assert.equal(r.audit.emitted, true); + assert.equal(r.audit.ext, '.blade.php'); + assert.deepEqual(det.calls.html, [file]); + assert.deepEqual(det.calls.text, []); + }); + + it('routes an engine:text entry through detectText instead', async () => { + writeExtensionsConfig([{ ext: '.blade.php', engine: 'text' }]); + const file = writeFixture('resources/views/card.blade.php', '
Hi
'); + const det = recordingDetector([finding('side-tab', 1)]); + const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); + assert.match(r.stdout, /side-tab/); + assert.deepEqual(det.calls.text, [file]); + assert.deepEqual(det.calls.html, []); + }); + + it('emits a clean ack for configured html-engine files, stays quiet for text-engine ones', async () => { + writeExtensionsConfig([ + { ext: '.blade.php' }, + { ext: '.d.ts.hbs', engine: 'text' }, + ]); + const blade = writeFixture('resources/views/clean.blade.php', '
Hi
'); + const rBlade = await runHook({ stdinJson: JSON.stringify(eventFor(blade)), env: {}, cwd, detector: recordingDetector([]) }); + assert.match(rBlade.stdout, /No deterministic design-quality issues found/); + assert.equal(rBlade.audit.kind, 'clean'); + + const hbs = writeFixture('templates/types.d.ts.hbs', 'export type X = {{name}};'); + const rHbs = await runHook({ stdinJson: JSON.stringify(eventFor(hbs)), env: {}, cwd, detector: recordingDetector([]) }); + assert.equal(rHbs.stdout, ''); + assert.equal(rHbs.audit.skipped, 'non-ui-ack'); + }); +}); + describe('Cursor hook scripts', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); @@ -1960,6 +2158,70 @@ describe('Cursor hook scripts', () => { assert.deepEqual(JSON.parse(out), { permission: 'allow' }); }); + it('preToolUse gates configured template extensions (issue #316)', () => { + const filePath = path.join(cwd, 'resources/views/card.blade.php'); + const content = ` + +
Hello
+ `; + const run = () => execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { + cwd: path.resolve('.'), + input: JSON.stringify({ + hook_event_name: 'preToolUse', + cwd, + tool_name: 'Write', + tool_input: { file_path: filePath, content }, + }), + env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, + encoding: 'utf-8', + }); + + // Without config the file is invisible to the gate: allowed untouched. + assert.equal(JSON.parse(run()).permission, 'allow'); + + // With a detector.extensions entry the same proposed write is scanned and denied. + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({ + detector: { extensions: [{ ext: '.blade.php' }] }, + })); + const payload = JSON.parse(run()); + assert.equal(payload.permission, 'deny'); + assert.match(payload.user_message, /card\.blade\.php/); + assert.match(payload.user_message, /side-tab/); + }); + + it('preToolUse routes configured html-engine templates through the HTML engine (issue #316)', () => { + // oversized-h1 is only detectable by the static HTML engine (detectText has + // no such rule), so a denial here proves the proposed content went through + // detectHtml rather than the old always-detectText path. + const filePath = path.join(cwd, 'resources/views/hero.blade.php'); + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({ + detector: { extensions: [{ ext: '.blade.php' }] }, + })); + + const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], { + cwd: path.resolve('.'), + input: JSON.stringify({ + hook_event_name: 'preToolUse', + cwd, + tool_name: 'Write', + tool_input: { + file_path: filePath, + content: '\n

This is a very long headline that keeps going on and on for a while

', + }, + }), + env: { ...process.env, IMPECCABLE_HOOK_LOG: '' }, + encoding: 'utf-8', + }); + + const payload = JSON.parse(out); + assert.equal(payload.permission, 'deny'); + assert.match(payload.user_message, /oversized-h1/); + }); + it('preToolUse denies shell heredoc writes that bypass the Write tool', () => { const filePath = path.join(cwd, 'src/ShellCard.html'); const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {