mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
co-authored by
Abdul Wahab
Cursor
parent
9927634d4f
commit
95b67ffa83
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')], {
|
||||
|
||||
Reference in New Issue
Block a user