From 410552b00eab37dbd31e808a1a06ca1c53bb625c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:17:52 +0000 Subject: [PATCH] Sync generated provider output --- .agents/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .claude/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .cursor/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .gemini/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .github/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .kiro/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .kiro/skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .../skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .pi/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .pi/skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .qoder/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .qoder/skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .rovodev/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .trae-cn/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .../skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- .trae/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- .trae/skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- plugin/skills/impeccable/reference/hooks.md | 4 +- .../impeccable/scripts/hook-before-edit.mjs | 36 ++++++++- plugin/skills/impeccable/scripts/hook-lib.mjs | 73 +++++++++++++++++-- 39 files changed, 1300 insertions(+), 169 deletions(-) diff --git a/.agents/skills/impeccable/reference/hooks.md b/.agents/skills/impeccable/reference/hooks.md index 8d22e33ff..b6f1a82c2 100644 --- a/.agents/skills/impeccable/reference/hooks.md +++ b/.agents/skills/impeccable/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 .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca ## 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/.agents/skills/impeccable/scripts/hook-before-edit.mjs b/.agents/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.agents/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.agents/skills/impeccable/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/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/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/.claude/skills/impeccable/reference/hooks.md b/.claude/skills/impeccable/reference/hooks.md index 9f52c7897..30f756793 100644 --- a/.claude/skills/impeccable/reference/hooks.md +++ b/.claude/skills/impeccable/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 .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca ## 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/.claude/skills/impeccable/scripts/hook-before-edit.mjs b/.claude/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.claude/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.claude/skills/impeccable/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/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/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/.cursor/skills/impeccable/reference/hooks.md b/.cursor/skills/impeccable/reference/hooks.md index dd74082b1..829dec839 100644 --- a/.cursor/skills/impeccable/reference/hooks.md +++ b/.cursor/skills/impeccable/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 .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca ## 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/.cursor/skills/impeccable/scripts/hook-before-edit.mjs b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.cursor/skills/impeccable/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/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/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/.gemini/skills/impeccable/reference/hooks.md b/.gemini/skills/impeccable/reference/hooks.md index 56c9a8ea1..e7bb58d14 100644 --- a/.gemini/skills/impeccable/reference/hooks.md +++ b/.gemini/skills/impeccable/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 .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca ## 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/.gemini/skills/impeccable/scripts/hook-before-edit.mjs b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.gemini/skills/impeccable/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/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/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/.github/skills/impeccable/reference/hooks.md b/.github/skills/impeccable/reference/hooks.md index 72117dd96..7c7413f96 100644 --- a/.github/skills/impeccable/reference/hooks.md +++ b/.github/skills/impeccable/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 .github/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca ## 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/.github/skills/impeccable/scripts/hook-before-edit.mjs b/.github/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.github/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.github/skills/impeccable/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/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/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/.kiro/skills/impeccable/reference/hooks.md b/.kiro/skills/impeccable/reference/hooks.md index 7088a4528..da1084bb9 100644 --- a/.kiro/skills/impeccable/reference/hooks.md +++ b/.kiro/skills/impeccable/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 .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card ## 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/.kiro/skills/impeccable/scripts/hook-before-edit.mjs b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.kiro/skills/impeccable/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/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/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/.opencode/skills/impeccable/reference/hooks.md b/.opencode/skills/impeccable/reference/hooks.md index 19c6a114d..901c3a6cc 100644 --- a/.opencode/skills/impeccable/reference/hooks.md +++ b/.opencode/skills/impeccable/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 .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/ ## 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/.opencode/skills/impeccable/scripts/hook-before-edit.mjs b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.opencode/skills/impeccable/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/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/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/.pi/skills/impeccable/reference/hooks.md b/.pi/skills/impeccable/reference/hooks.md index 5d6e128a8..7f2f6ee4a 100644 --- a/.pi/skills/impeccable/reference/hooks.md +++ b/.pi/skills/impeccable/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 .pi/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.t ## 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/.pi/skills/impeccable/scripts/hook-before-edit.mjs b/.pi/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.pi/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.pi/skills/impeccable/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/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/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/.qoder/skills/impeccable/reference/hooks.md b/.qoder/skills/impeccable/reference/hooks.md index 1a0143a03..1b154f701 100644 --- a/.qoder/skills/impeccable/reference/hooks.md +++ b/.qoder/skills/impeccable/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 .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Car ## 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/.qoder/skills/impeccable/scripts/hook-before-edit.mjs b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.qoder/skills/impeccable/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/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/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/.rovodev/skills/impeccable/reference/hooks.md b/.rovodev/skills/impeccable/reference/hooks.md index 074300c8b..00c0b5088 100644 --- a/.rovodev/skills/impeccable/reference/hooks.md +++ b/.rovodev/skills/impeccable/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 .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C ## 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/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.rovodev/skills/impeccable/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/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/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/.trae-cn/skills/impeccable/reference/hooks.md b/.trae-cn/skills/impeccable/reference/hooks.md index 5d8440367..e0b51cc33 100644 --- a/.trae-cn/skills/impeccable/reference/hooks.md +++ b/.trae-cn/skills/impeccable/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 .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C ## 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/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae-cn/skills/impeccable/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/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/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/.trae/skills/impeccable/reference/hooks.md b/.trae/skills/impeccable/reference/hooks.md index 5a8ec9d80..05d4f649e 100644 --- a/.trae/skills/impeccable/reference/hooks.md +++ b/.trae/skills/impeccable/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 .trae/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card ## 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/.trae/skills/impeccable/scripts/hook-before-edit.mjs b/.trae/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/.trae/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae/skills/impeccable/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/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/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/plugin/skills/impeccable/reference/hooks.md b/plugin/skills/impeccable/reference/hooks.md index 9f52c7897..30f756793 100644 --- a/plugin/skills/impeccable/reference/hooks.md +++ b/plugin/skills/impeccable/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 .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca ## 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/plugin/skills/impeccable/scripts/hook-before-edit.mjs b/plugin/skills/impeccable/scripts/hook-before-edit.mjs index e2d897b11..f3f010bf8 100644 --- a/plugin/skills/impeccable/scripts/hook-before-edit.mjs +++ b/plugin/skills/impeccable/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/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index bcbf617c7..b649211ec 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/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,