Add configurable detector extensions for server-side templates (#347)

* Add configurable detector extensions for server-side templates (#316)

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

* Use imperative voice for detector.extensions guidance in hooks.md

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

* Route html-engine extensions through detectHtml in the Cursor pre-write gate

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

* Prefer the longest matching suffix in matchConfiguredExtension

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

---------

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-07-06 14:17:23 -07:00
committed by GitHub
co-authored by Abdul Wahab Cursor
parent 9927634d4f
commit 95b67ffa83
4 changed files with 362 additions and 13 deletions
+3 -1
View File
@@ -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.
+32 -4
View File
@@ -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 });
}
+65 -8
View File
@@ -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,
+262
View File
@@ -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', '<div class="bg-gradient-to-r">Hi</div>');
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', '<div class="bg-gradient-to-r">Hi</div>');
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', '<div>Hi</div>');
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', '<div>Hi</div>');
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 = `
<style>
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
</style>
<div class="card">Hello</div>
`;
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: '<style>h1 { font-size: 84px; }</style>\n<h1>This is a very long headline that keeps going on and on for a while</h1>',
},
}),
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')], {